Added to track the net balance and offset of the original entries.

This commit is contained in:
依瑪貓 2023-03-17 22:32:01 +08:00
parent 40e329d37f
commit d88b3ac770
38 changed files with 3103 additions and 183 deletions

View File

@ -21,6 +21,7 @@ from __future__ import annotations
import re
import typing as t
from datetime import date
from decimal import Decimal
import sqlalchemy as sa
@ -568,6 +569,21 @@ class Transaction(db.Model):
return False
return True
@property
def can_delete(self) -> bool:
"""Returns whether the transaction can be deleted.
:return: True if the transaction can be deleted, or False otherwise.
"""
if not hasattr(self, "__can_delete"):
def has_offset() -> bool:
for entry in self.entries:
if len(entry.offsets) > 0:
return True
return False
setattr(self, "__can_delete", not has_offset())
return getattr(self, "__can_delete")
def delete(self) -> None:
"""Deletes the transaction.
@ -624,6 +640,21 @@ class JournalEntry(db.Model):
amount = db.Column(db.Numeric(14, 2), nullable=False)
"""The amount."""
def __str__(self) -> str:
"""Returns the string representation of the journal entry.
:return: The string representation of the journal entry.
"""
if not hasattr(self, "__str"):
from accounting.template_filters import format_date, format_amount
setattr(self, "__str",
gettext("%(date)s %(summary)s %(amount)s",
date=format_date(self.transaction.date),
summary="" if self.summary is None
else self.summary,
amount=format_amount(self.amount)))
return getattr(self, "__str")
@property
def eid(self) -> int | None:
"""Returns the journal entry ID. This is the alternative name of the
@ -649,6 +680,20 @@ class JournalEntry(db.Model):
"""
return self.amount if self.is_debit else None
@property
def is_original_entry(self) -> bool:
"""Returns whether the entry is an original entry.
:return: True if the entry is an original entry, or False otherwise.
"""
if not self.account.is_offset_needed:
return False
if self.account.base_code[0] == "1" and not self.is_debit:
return False
if self.account.base_code[0] == "2" and self.is_debit:
return False
return True
@property
def credit(self) -> Decimal | None:
"""Returns the credit amount.
@ -656,3 +701,45 @@ class JournalEntry(db.Model):
:return: The credit amount, or None if this is not a credit entry.
"""
return None if self.is_debit else self.amount
@property
def net_balance(self) -> Decimal:
"""Returns the net balance.
:return: The net balance.
"""
if not hasattr(self, "__net_balance"):
setattr(self, "__net_balance", self.amount + sum(
[x.amount if x.is_debit == self.is_debit else -x.amount
for x in self.offsets]))
return getattr(self, "__net_balance")
@net_balance.setter
def net_balance(self, net_balance: Decimal) -> None:
"""Sets the net balance.
:param net_balance: The net balance.
:return: None.
"""
setattr(self, "__net_balance", net_balance)
@property
def query_values(self) -> tuple[list[str], list[str]]:
"""Returns the values to be queried.
:return: The values to be queried.
"""
def format_amount(value: Decimal) -> str:
whole: int = int(value)
frac: Decimal = (value - whole).normalize()
return str(whole) + str(abs(frac))[1:]
txn_day: date = self.transaction.date
summary: str = "" if self.summary is None else self.summary
return ([summary],
[str(txn_day.year),
"{}/{}".format(txn_day.year, txn_day.month),
"{}/{}".format(txn_day.month, txn_day.day),
"{}/{}/{}".format(txn_day.year, txn_day.month, txn_day.day),
format_amount(self.amount),
format_amount(self.net_balance)])

View File

@ -31,6 +31,9 @@
color: #141619;
background-color: #D3D3D4;
}
.form-control.accounting-disabled {
background-color: #e9ecef;
}
/** The toolbar */
.accounting-toolbar {
@ -113,6 +116,33 @@
border-bottom: thick double slategray;
}
/* Links between objects */
.accounting-original-entry {
border-top: thin solid darkslategray;
padding: 0.2rem 0.5rem;
}
.accounting-original-entry a {
color: inherit;
text-decoration: none;
}
.accounting-original-entry a:hover {
color: inherit;
}
.accounting-offset-entries {
border-top: thin solid darkslategray;
padding: 0.2rem 0.5rem;
}
.accounting-offset-entries ul li {
list-style: none;
}
.accounting-offset-entries ul li a {
color: inherit;
text-decoration: none;
}
.accounting-offset-entries ul li a:hover {
color: inherit;
}
/** The option selector */
.accounting-selector-list {
height: 20rem;
@ -150,6 +180,9 @@
font-weight: bolder;
border-top: thick double slategray;
}
.accounting-entry-editor-original-entry-content {
width: calc(100% - 3rem);
}
/* The report table */
.accounting-report-table-header, .accounting-report-table-footer {

View File

@ -111,7 +111,7 @@ class AccountSelector {
};
for (const option of this.#options) {
option.onclick = () => {
this.#entryEditor.saveAccount(option.dataset.code, option.dataset.content);
this.#entryEditor.saveAccount(option.dataset.code, option.dataset.content, option.classList.contains("accounting-account-is-offset-needed"));
};
}
this.#query.addEventListener("input", () => {

View File

@ -57,6 +57,36 @@ class JournalEntryEditor {
*/
#prefix = "accounting-entry-editor"
/**
* The container of the original entry
* @type {HTMLDivElement}
*/
#originalEntryContainer;
/**
* The control of the original entry
* @type {HTMLDivElement}
*/
#originalEntryControl;
/**
* The original entry
* @type {HTMLDivElement}
*/
#originalEntry;
/**
* The error message of the original entry
* @type {HTMLDivElement}
*/
#originalEntryError;
/**
* The delete button of the original entry
* @type {HTMLButtonElement}
*/
#originalEntryDelete;
/**
* The control of the summary
* @type {HTMLDivElement}
@ -109,7 +139,7 @@ class JournalEntryEditor {
* The journal entry to edit
* @type {JournalEntrySubForm|null}
*/
#entry;
entry;
/**
* The debit or credit entry side sub-form
@ -124,6 +154,11 @@ class JournalEntryEditor {
constructor() {
this.#element = document.getElementById(this.#prefix);
this.#modal = document.getElementById(this.#prefix + "-modal");
this.#originalEntryContainer = document.getElementById(this.#prefix + "-original-entry-container");
this.#originalEntryControl = document.getElementById(this.#prefix + "-original-entry-control");
this.#originalEntry = document.getElementById(this.#prefix + "-original-entry");
this.#originalEntryError = document.getElementById(this.#prefix + "-original-entry-error");
this.#originalEntryDelete = document.getElementById(this.#prefix + "-original-entry-delete");
this.#summaryControl = document.getElementById(this.#prefix + "-summary-control");
this.#summary = document.getElementById(this.#prefix + "-summary");
this.#summaryError = document.getElementById(this.#prefix + "-summary-error");
@ -131,25 +166,90 @@ class JournalEntryEditor {
this.#account = document.getElementById(this.#prefix + "-account");
this.#accountError = document.getElementById(this.#prefix + "-account-error")
this.#amount = document.getElementById(this.#prefix + "-amount");
this.#amountError = document.getElementById(this.#prefix + "-amount-error")
this.#amountError = document.getElementById(this.#prefix + "-amount-error");
this.#originalEntryControl.onclick = () => OriginalEntrySelector.start(this, this.#originalEntry.dataset.id);
this.#originalEntryDelete.onclick = () => this.clearOriginalEntry();
this.#summaryControl.onclick = () => {
SummaryEditor.start(this, this.#summary.dataset.value);
};
this.#accountControl.onclick = () => {
AccountSelector.start(this, this.entryType);
}
this.#amount.onchange = () => {
this.#validateAmount();
}
this.#element.onsubmit = () => {
if (this.#validate()) {
if (this.#entry === null) {
this.#entry = this.#side.addJournalEntry();
if (this.entry === null) {
this.entry = this.#side.addJournalEntry();
}
this.#entry.save(this.#account.dataset.code, this.#account.dataset.text, this.#summary.dataset.value, this.#amount.value);
this.entry.save("isOriginalEntry" in this.#element.dataset,this.#originalEntry.dataset.id, this.#originalEntry.dataset.date, this.#originalEntry.dataset.text, this.#account.dataset.code, this.#account.dataset.text, this.#summary.dataset.value, this.#amount.value);
bootstrap.Modal.getInstance(this.#modal).hide();
}
return false;
};
}
/**
* Saves the original entry from the original entry selector.
*
* @param originalEntry {OriginalEntry} the original entry
*/
saveOriginalEntry(originalEntry) {
delete this.#element.dataset.isOriginalEntry;
this.#originalEntryContainer.classList.remove("d-none");
this.#originalEntryControl.classList.add("accounting-not-empty");
this.#originalEntry.dataset.id = originalEntry.id;
this.#originalEntry.dataset.date = originalEntry.date;
this.#originalEntry.dataset.text = originalEntry.text;
this.#originalEntry.innerText = originalEntry.text;
this.#setEnableSummaryAccount(false);
if (originalEntry.summary === "") {
this.#summaryControl.classList.remove("accounting-not-empty");
} else {
this.#summaryControl.classList.add("accounting-not-empty");
}
this.#summary.dataset.value = originalEntry.summary;
this.#summary.innerText = originalEntry.summary;
this.#accountControl.classList.add("accounting-not-empty");
this.#account.dataset.code = originalEntry.accountCode;
this.#account.dataset.text = originalEntry.accountText;
this.#account.innerText = originalEntry.accountText;
this.#amount.value = String(originalEntry.netBalance);
this.#amount.max = String(originalEntry.netBalance);
this.#amount.min = "0";
this.#validate();
}
/**
* Clears the original entry.
*
*/
clearOriginalEntry() {
delete this.#element.dataset.isOriginalEntry;
this.#originalEntryContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty");
this.#originalEntry.dataset.id = "";
this.#originalEntry.dataset.date = "";
this.#originalEntry.dataset.text = "";
this.#originalEntry.innerText = "";
this.#setEnableSummaryAccount(true);
this.#accountControl.classList.remove("accounting-not-empty");
this.#account.dataset.code = "";
this.#account.dataset.text = "";
this.#account.innerText = "";
this.#amount.max = "";
}
/**
* Returns the currency code.
*
* @return {string} the currency code
*/
getCurrencyCode() {
return this.#side.currency.getCurrencyCode();
}
/**
* Returns the transaction form.
*
@ -172,6 +272,7 @@ class JournalEntryEditor {
}
this.#summary.dataset.value = summary;
this.#summary.innerText = summary;
this.#validateSummary();
bootstrap.Modal.getOrCreateInstance(this.#modal).show();
}
@ -181,8 +282,14 @@ class JournalEntryEditor {
* @param summary {string} the summary
* @param accountCode {string} the account code
* @param accountText {string} the account text
* @param isAccountOffsetNeeded {boolean} true if the journal entries in the account need offset, or false otherwise
*/
saveSummaryWithAccount(summary, accountCode, accountText) {
saveSummaryWithAccount(summary, accountCode, accountText, isAccountOffsetNeeded) {
if (isAccountOffsetNeeded) {
this.#element.dataset.isOriginalEntry = "true";
} else {
delete this.#element.dataset.isOriginalEntry;
}
this.#accountControl.classList.add("accounting-not-empty");
this.#account.dataset.code = accountCode;
this.#account.dataset.text = accountText;
@ -205,6 +312,7 @@ class JournalEntryEditor {
*
*/
clearAccount() {
delete this.#element.dataset.isOriginalEntry;
this.#accountControl.classList.remove("accounting-not-empty");
this.#account.dataset.code = "";
this.#account.dataset.text = "";
@ -217,8 +325,14 @@ class JournalEntryEditor {
*
* @param code {string} the account code
* @param text {string} the account text
* @param isOffsetNeeded {boolean} true if the journal entries in the account need offset or false otherwise
*/
saveAccount(code, text) {
saveAccount(code, text, isOffsetNeeded) {
if (isOffsetNeeded) {
this.#element.dataset.isOriginalEntry = "true";
} else {
delete this.#element.dataset.isOriginalEntry;
}
this.#accountControl.classList.add("accounting-not-empty");
this.#account.dataset.code = code;
this.#account.dataset.text = text;
@ -233,12 +347,25 @@ class JournalEntryEditor {
*/
#validate() {
let isValid = true;
isValid = this.#validateOriginalEntry() && isValid;
isValid = this.#validateSummary() && isValid;
isValid = this.#validateAccount() && isValid;
isValid = this.#validateAmount() && isValid
return isValid;
}
/**
* Validates the original entry.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
#validateOriginalEntry() {
this.#originalEntryControl.classList.remove("is-invalid");
this.#originalEntryError.innerText = "";
return true;
}
/**
* Validates the summary.
*
@ -281,8 +408,29 @@ class JournalEntryEditor {
this.#amountError.innerText = A_("Please fill in the amount.");
return false;
}
const amount =new Decimal(this.#amount.value);
if (amount.lessThanOrEqualTo(0)) {
this.#amount.classList.add("is-invalid");
this.#amountError.innerText = A_("Please fill in a positive amount.");
return false;
}
if (this.#amount.max !== "") {
if (amount.greaterThan(new Decimal(this.#amount.max))) {
this.#amount.classList.add("is-invalid");
this.#amountError.innerText = A_("The amount must not exceed the net balance %(balance)s of the original entry.", {balance: new Decimal(this.#amount.max)});
return false;
}
}
if (this.#amount.min !== "") {
const min = new Decimal(this.#amount.min);
if (amount.lessThan(min)) {
this.#amount.classList.add("is-invalid");
this.#amountError.innerText = A_("The amount must not be less than the offset total %(total)s.", {total: formatDecimal(min)});
return false;
}
}
this.#amount.classList.remove("is-invalid");
this.#amount.innerText = "";
this.#amountError.innerText = "";
return true;
}
@ -292,24 +440,33 @@ class JournalEntryEditor {
* @param side {DebitCreditSideSubForm} the debit or credit side sub-form
*/
#onAddNew(side) {
this.#entry = null;
this.entry = null;
this.#side = side;
this.entryType = this.#side.entryType;
this.#element.dataset.entryType = side.entryType;
this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + side.entryType + "-modal";
delete this.#element.dataset.isOriginalEntry;
this.#originalEntryContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty");
this.#originalEntryControl.classList.remove("is-invalid");
this.#originalEntry.dataset.id = "";
this.#originalEntry.dataset.date = "";
this.#originalEntry.dataset.text = "";
this.#originalEntry.innerText = "";
this.#setEnableSummaryAccount(true);
this.#summaryControl.classList.remove("accounting-not-empty");
this.#summaryControl.classList.remove("is-invalid");
this.#summary.dataset.value = "";
this.#summary.innerText = ""
this.#summaryError.innerText = ""
this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + side.entryType + "-modal";
this.#accountControl.classList.remove("accounting-not-empty");
this.#accountControl.classList.remove("is-invalid");
this.#account.innerText = "";
this.#account.dataset.code = "";
this.#account.dataset.text = "";
this.#account.innerText = "";
this.#accountError.innerText = "";
this.#amount.value = "";
this.#amount.max = "";
this.#amount.min = "0";
this.#amount.classList.remove("is-invalid");
this.#amountError.innerText = "";
}
@ -318,17 +475,37 @@ class JournalEntryEditor {
* Edits a journal entry.
*
* @param entry {JournalEntrySubForm} the journal entry sub-form
* @param originalEntryId {string} the ID of the original entry
* @param originalEntryDate {string} the date of the original entry
* @param originalEntryText {string} the text of the original entry
* @param summary {string} the summary
* @param accountCode {string} the account code
* @param accountText {string} the account text
* @param amount {string} the amount
* @param amountMin {string} the minimal amount
*/
#onEdit(entry, summary, accountCode, accountText, amount) {
this.#entry = entry;
#onEdit(entry, originalEntryId, originalEntryDate, originalEntryText, summary, accountCode, accountText, amount, amountMin) {
this.entry = entry;
this.#side = entry.side;
this.entryType = this.#side.entryType;
this.#element.dataset.entryType = entry.entryType;
this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + entry.entryType + "-modal";
if (entry.isOriginalEntry()) {
this.#element.dataset.isOriginalEntry = "true";
} else {
delete this.#element.dataset.isOriginalEntry;
}
if (originalEntryId === "") {
this.#originalEntryContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty");
} else {
this.#originalEntryContainer.classList.remove("d-none");
this.#originalEntryControl.classList.add("accounting-not-empty");
}
this.#originalEntry.dataset.id = originalEntryId;
this.#originalEntry.dataset.date = originalEntryDate;
this.#originalEntry.dataset.text = originalEntryText;
this.#originalEntry.innerText = originalEntryText;
this.#setEnableSummaryAccount(!entry.isMatched && originalEntryId === "");
if (summary === "") {
this.#summaryControl.classList.remove("accounting-not-empty");
} else {
@ -336,16 +513,58 @@ class JournalEntryEditor {
}
this.#summary.dataset.value = summary;
this.#summary.innerText = summary;
this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + entry.entryType + "-modal";
if (accountCode === "") {
this.#accountControl.classList.remove("accounting-not-empty");
} else {
this.#accountControl.classList.add("accounting-not-empty");
}
this.#account.innerText = accountText;
this.#account.dataset.code = accountCode;
this.#account.dataset.text = accountText;
this.#account.innerText = accountText;
this.#amount.value = amount;
const maxAmount = this.#getMaxAmount();
this.#amount.max = maxAmount === null? "": maxAmount;
this.#amount.min = amountMin;
this.#validate();
}
/**
* Finds out the max amount.
*
* @return {Decimal|null} the max amount
*/
#getMaxAmount() {
if (this.#originalEntry.dataset.id === "") {
return null;
}
return OriginalEntrySelector.getNetBalance(this.entry, this.getTransactionForm(), this.#originalEntry.dataset.id);
}
/**
* Sets the enable status of the summary and account.
*
* @param isEnabled {boolean} true to enable, or false otherwise
*/
#setEnableSummaryAccount(isEnabled) {
if (isEnabled) {
this.#summaryControl.dataset.bsToggle = "modal";
this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + this.#side.entryType + "-modal";
this.#summaryControl.classList.remove("accounting-disabled");
this.#summaryControl.classList.add("accounting-clickable");
this.#accountControl.dataset.bsToggle = "modal";
this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + this.#side.entryType + "-modal";
this.#accountControl.classList.remove("accounting-disabled");
this.#accountControl.classList.add("accounting-clickable");
} else {
this.#summaryControl.dataset.bsToggle = "";
this.#summaryControl.dataset.bsTarget = "";
this.#summaryControl.classList.add("accounting-disabled");
this.#summaryControl.classList.remove("accounting-clickable");
this.#accountControl.dataset.bsToggle = "";
this.#accountControl.dataset.bsTarget = "";
this.#accountControl.classList.add("accounting-disabled");
this.#accountControl.classList.remove("accounting-clickable");
}
}
/**
@ -375,12 +594,16 @@ class JournalEntryEditor {
* Edits a journal entry.
*
* @param entry {JournalEntrySubForm} the journal entry sub-form
* @param originalEntryId {string} the ID of the original entry
* @param originalEntryDate {string} the date of the original entry
* @param originalEntryText {string} the text of the original entry
* @param summary {string} the summary
* @param accountCode {string} the account code
* @param accountText {string} the account text
* @param amount {string} the amount
* @param amountMin {string} the minimal amount
*/
static edit(entry, summary, accountCode, accountText, amount) {
this.#editor.#onEdit(entry, summary, accountCode, accountText, amount);
static edit(entry, originalEntryId, originalEntryDate, originalEntryText, summary, accountCode, accountText, amount, amountMin) {
this.#editor.#onEdit(entry, originalEntryId, originalEntryDate, originalEntryText, summary, accountCode, accountText, amount, amountMin);
}
}

View File

@ -0,0 +1,447 @@
/* The Mia! Accounting Flask Project
* original-entry-selector.js: The JavaScript for the original entry 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/3/10
*/
"use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
OriginalEntrySelector.initialize();
});
/**
* The original entry selector.
*
*/
class OriginalEntrySelector {
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix = "accounting-original-entry-selector";
/**
* The modal of the original entry editor
* @type {HTMLDivElement}
*/
#modal;
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
/**
* The error message when the query has no result
* @type {HTMLParagraphElement}
*/
#queryNoResult;
/**
* The option list
* @type {HTMLUListElement}
*/
#optionList;
/**
* The options
* @type {OriginalEntry[]}
*/
#options;
/**
* The options by their ID
* @type {Object.<string, OriginalEntry>}
*/
#optionById;
/**
* The journal entry editor.
* @type {JournalEntryEditor}
*/
entryEditor;
/**
* Constructs an original entry selector.
*
*/
constructor() {
this.#modal = document.getElementById(this.#prefix + "-modal");
this.#query = document.getElementById(this.#prefix + "-query");
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
this.#optionList = document.getElementById(this.#prefix + "-option-list");
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option")).map((element) => new OriginalEntry(this, element));
this.#optionById = {};
for (const option of this.#options) {
this.#optionById[option.id] = option;
}
this.#query.addEventListener("input", () => {
this.#filterOptions();
});
}
/**
* Returns the net balance for an original entry.
*
* @param currentEntry {JournalEntrySubForm} the journal entry sub-form that is currently editing
* @param form {TransactionForm} the transaction form
* @param originalEntryId {string} the ID of the original entry
* @return {Decimal} the net balance of the original entry
*/
#getNetBalance(currentEntry, form, originalEntryId) {
const otherEntries = form.getEntries().filter((entry) => entry !== currentEntry);
let otherOffset = new Decimal(0);
for (const otherEntry of otherEntries) {
if (otherEntry.getOriginalEntryId() === originalEntryId) {
const amount = otherEntry.getAmount();
if (amount !== null) {
otherOffset = otherOffset.plus(amount);
}
}
}
return this.#optionById[originalEntryId].bareNetBalance.minus(otherOffset);
}
/**
* Updates the net balances, subtracting the offset amounts on the form but the currently editing journal entry
*
*/
#updateNetBalances() {
const otherEntries = this.entryEditor.getTransactionForm().getEntries().filter((entry) => entry !== this.entryEditor.entry);
const otherOffsets = {}
for (const otherEntry of otherEntries) {
const otherOriginalEntryId = otherEntry.getOriginalEntryId();
const amount = otherEntry.getAmount();
if (otherOriginalEntryId === null || amount === null) {
continue;
}
if (!(otherOriginalEntryId in otherOffsets)) {
otherOffsets[otherOriginalEntryId] = new Decimal("0");
}
otherOffsets[otherOriginalEntryId] = otherOffsets[otherOriginalEntryId].plus(amount);
}
for (const option of this.#options) {
if (option.id in otherOffsets) {
option.updateNetBalance(otherOffsets[option.id]);
} else {
option.resetNetBalance();
}
}
}
/**
* Filters the options.
*
*/
#filterOptions() {
let hasAnyMatched = false;
for (const option of this.#options) {
if (option.isMatched(this.#modal.dataset.entryType, this.#modal.dataset.currencyCode, this.#query.value)) {
option.setShown(true);
hasAnyMatched = true;
} else {
option.setShown(false);
}
}
if (!hasAnyMatched) {
this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none");
} else {
this.#optionList.classList.remove("d-none");
this.#queryNoResult.classList.add("d-none");
}
}
/**
* The callback when the original entry selector is shown.
*
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @param originalEntryId {string|null} the ID of the original entry
*/
#onOpen(entryEditor, originalEntryId) {
this.entryEditor = entryEditor
this.#modal.dataset.currencyCode = entryEditor.getCurrencyCode();
this.#modal.dataset.entryType = entryEditor.entryType;
for (const option of this.#options) {
option.setActive(option.id === originalEntryId);
}
this.#query.value = "";
this.#updateNetBalances();
this.#filterOptions();
}
/**
* The original entry selector.
* @type {OriginalEntrySelector}
*/
static #selector;
/**
* Initializes the original entry selector.
*
*/
static initialize() {
this.#selector = new OriginalEntrySelector();
}
/**
* Starts the original entry selector.
*
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @param originalEntryId {string|null} the ID of the original entry
*/
static start(entryEditor, originalEntryId = null) {
this.#selector.#onOpen(entryEditor, originalEntryId);
}
/**
* Returns the net balance for an original entry.
*
* @param currentEntry {JournalEntrySubForm} the journal entry sub-form that is currently editing
* @param form {TransactionForm} the transaction form
* @param originalEntryId {string} the ID of the original entry
* @return {Decimal} the net balance of the original entry
*/
static getNetBalance(currentEntry, form, originalEntryId) {
return this.#selector.#getNetBalance(currentEntry, form, originalEntryId);
}
}
/**
* An original entry.
*
*/
class OriginalEntry {
/**
* The original entry selector
* @type {OriginalEntrySelector}
*/
#selector;
/**
* The element
* @type {HTMLLIElement}
*/
#element;
/**
* The ID
* @type {string}
*/
id;
/**
* The date
* @type {string}
*/
date;
/**
* The entry type, either "debit" or "credit"
* @type {string}
*/
#entryType;
/**
* The currency code
* @type {string}
*/
#currencyCode;
/**
* The account code
* @type {string}
*/
accountCode;
/**
* The account text
* @type {string}
*/
accountText;
/**
* The summary
* @type {string}
*/
summary;
/**
* The net balance, without the offset amounts on the form
* @type {Decimal}
*/
bareNetBalance;
/**
* The net balance
* @type {Decimal}
*/
netBalance;
/**
* The text of the net balance
* @type {HTMLSpanElement}
*/
netBalanceText;
/**
* The text representation of the original entry
* @type {string}
*/
text;
/**
* The values to query against
* @type {string[][]}
*/
#queryValues;
/**
* Constructs an original entry.
*
* @param selector {OriginalEntrySelector} the original entry selector
* @param element {HTMLLIElement} the element
*/
constructor(selector, element) {
this.#selector = selector;
this.#element = element;
this.id = element.dataset.id;
this.date = element.dataset.date;
this.#entryType = element.dataset.entryType;
this.#currencyCode = element.dataset.currencyCode;
this.accountCode = element.dataset.accountCode;
this.accountText = element.dataset.accountText;
this.summary = element.dataset.summary;
this.bareNetBalance = new Decimal(element.dataset.netBalance);
this.netBalance = this.bareNetBalance;
this.netBalanceText = document.getElementById("accounting-original-entry-selector-option-" + this.id + "-net-balance");
this.text = element.dataset.text;
this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => this.#selector.entryEditor.saveOriginalEntry(this);
}
/**
* Resets the net balance to its initial value, without the offset amounts on the form.
*
*/
resetNetBalance() {
if (this.netBalance !== this.bareNetBalance) {
this.netBalance = this.bareNetBalance;
this.#updateNetBalanceText();
}
}
/**
* Updates the net balance with an offset.
*
* @param offset {Decimal} the offset to be added to the net balance
*/
updateNetBalance(offset) {
this.netBalance = this.bareNetBalance.minus(offset);
this.#updateNetBalanceText();
}
/**
* Updates the text display of the net balance.
*
*/
#updateNetBalanceText() {
this.netBalanceText.innerText = formatDecimal(this.netBalance);
}
/**
* Returns whether the original matches.
*
* @param entryType {string} the entry type, either "debit" or "credit"
* @param currencyCode {string} the currency code
* @param query {string|null} the query term
*/
isMatched(entryType, currencyCode, query = null) {
return this.netBalance.greaterThan(0)
&& this.date <= this.#selector.entryEditor.getTransactionForm().getDate()
&& this.#isEntryTypeMatches(entryType)
&& this.#currencyCode === currencyCode
&& this.#isQueryMatches(query);
}
/**
* Returns whether the original entry matches the entry type.
*
* @param entryType {string} the entry type, either "debit" or credit
* @return {boolean} true if the option matches, or false otherwise
*/
#isEntryTypeMatches(entryType) {
return (entryType === "debit" && this.#entryType === "credit")
|| (entryType === "credit" && this.#entryType === "debit");
}
/**
* Returns whether the original entry matches the query.
*
* @param query {string|null} the query term
* @return {boolean} true if the option matches, or false otherwise
*/
#isQueryMatches(query) {
if (query === "") {
return true;
}
for (const queryValue of this.#queryValues[0]) {
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
return true;
}
}
for (const queryValue of this.#queryValues[1]) {
if (queryValue === query) {
return true;
}
}
return false;
}
/**
* Sets whether the option is shown.
*
* @param isShown {boolean} true to show, or false otherwise
*/
setShown(isShown) {
if (isShown) {
this.#element.classList.remove("d-none");
} else {
this.#element.classList.add("d-none");
}
}
/**
* Sets whether the option is active.
*
* @param isActive {boolean} true if active, or false otherwise
*/
setActive(isActive) {
if (isActive) {
this.#element.classList.add("active");
} else {
this.#element.classList.remove("active");
}
}
}

View File

@ -69,6 +69,12 @@ class SummaryEditor {
*/
summary;
/**
* The button to the original entry selector
* @type {HTMLButtonElement}
*/
#offsetButton;
/**
* The number input
* @type {HTMLInputElement}
@ -116,6 +122,7 @@ class SummaryEditor {
this.prefix = "accounting-summary-editor-" + form.dataset.entryType;
this.#modal = document.getElementById(this.prefix + "-modal");
this.summary = document.getElementById(this.prefix + "-summary");
this.#offsetButton = document.getElementById(this.prefix + "-offset");
this.number = document.getElementById(this.prefix + "-annotation-number");
this.note = document.getElementById(this.prefix + "-annotation-note");
// noinspection JSValidateTypes
@ -128,6 +135,7 @@ class SummaryEditor {
this.currentTab = this.tabPlanes.general;
this.#initializeSuggestedAccounts();
this.summary.onchange = () => this.#onSummaryChange();
this.#offsetButton.onclick = () => OriginalEntrySelector.start(this.#entryEditor);
this.#form.onsubmit = () => {
if (this.currentTab.validate()) {
this.#submit();
@ -210,7 +218,7 @@ class SummaryEditor {
#submit() {
bootstrap.Modal.getOrCreateInstance(this.#modal).hide();
if (this.#selectedAccount !== null) {
this.#entryEditor.saveSummaryWithAccount(this.summary.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text);
this.#entryEditor.saveSummaryWithAccount(this.summary.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-offset-needed"));
} else {
this.#entryEditor.saveSummary(this.summary.value);
}

View File

@ -159,10 +159,22 @@ class TransactionForm {
this.#currencies[0].deleteButton.classList.add("d-none");
} else {
for (const currency of this.#currencies) {
const isAnyEntryMatched = () => {
for (const entry of currency.getEntries()) {
if (entry.isMatched) {
return true;
}
}
return false;
};
if (isAnyEntryMatched()) {
currency.deleteButton.classList.add("d-none");
} else {
currency.deleteButton.classList.remove("d-none");
}
}
}
}
/**
* Initializes the drag and drop reordering on the currency sub-forms.
@ -178,6 +190,20 @@ class TransactionForm {
});
}
/**
* Returns all the journal entries in the form.
*
* @param entryType {string|null} the entry type, either "debit" or "credit", or null for both
* @return {JournalEntrySubForm[]} all the journal entry sub-forms
*/
getEntries(entryType = null) {
const entries = [];
for (const currency of this.#currencies) {
entries.push(...currency.getEntries(entryType));
}
return entries;
}
/**
* Returns the account codes used in the form.
*
@ -185,11 +211,35 @@ class TransactionForm {
* @return {string[]} the account codes used in the form
*/
getAccountCodesUsed(entryType) {
let inUse = [];
for (const currency of this.#currencies) {
inUse = inUse.concat(currency.getAccountCodesUsed(entryType));
return this.getEntries(entryType).map((entry) => entry.getAccountCode())
.filter((code) => code !== null);
}
return inUse;
/**
* Returns the date.
*
* @return {string} the date
*/
getDate() {
return this.#date.value;
}
/**
* Updates the minimal date.
*
*/
updateMinDate() {
let lastOriginalEntryDate = null;
for (const entry of this.getEntries()) {
const date = entry.getOriginalEntryDate();
if (date !== null) {
if (lastOriginalEntryDate === null || lastOriginalEntryDate < date) {
lastOriginalEntryDate = date;
}
}
}
this.#date.min = lastOriginalEntryDate === null? "": lastOriginalEntryDate;
this.#validateDate();
}
/**
@ -218,6 +268,11 @@ class TransactionForm {
this.#dateError.innerText = A_("Please fill in the date.");
return false;
}
if (this.#date.value < this.#date.min) {
this.#date.classList.add("is-invalid");
this.#dateError.innerText = A_("The date cannot be earlier than the original entries.");
return false;
}
this.#date.classList.remove("is-invalid");
this.#dateError.innerText = "";
return true;
@ -330,6 +385,18 @@ class CurrencySubForm {
*/
no;
/**
* The currency code
* @type {HTMLInputElement}
*/
#code;
/**
* The currency code selector
* @type {HTMLSelectElement}
*/
#codeSelect;
/**
* The button to delete the currency
* @type {HTMLButtonElement}
@ -362,11 +429,16 @@ class CurrencySubForm {
this.#control = document.getElementById(this.#prefix + "-control");
this.#error = document.getElementById(this.#prefix + "-error");
this.no = document.getElementById(this.#prefix + "-no");
this.#code = document.getElementById(this.#prefix + "-code");
this.#codeSelect = document.getElementById(this.#prefix + "-code-select");
this.deleteButton = document.getElementById(this.#prefix + "-delete");
const debitElement = document.getElementById(this.#prefix + "-debit");
this.#debit = debitElement === null? null: new DebitCreditSideSubForm(this, debitElement, "debit");
const creditElement = document.getElementById(this.#prefix + "-credit");
this.#credit = creditElement == null? null: new DebitCreditSideSubForm(this, creditElement, "credit");
this.#codeSelect.onchange = () => {
this.#code.value = this.#codeSelect.value;
};
this.deleteButton.onclick = () => {
this.element.parentElement.removeChild(this.element);
this.form.deleteCurrency(this);
@ -374,18 +446,45 @@ class CurrencySubForm {
}
/**
* Returns the account codes used in the form.
* Returns the currency code.
*
* @param entryType {string} the entry type, either "debit" or "credit"
* @return {string[]} the account codes used in the form
* @return {string} the currency code
*/
getAccountCodesUsed(entryType) {
if (entryType === "debit") {
return this.#debit.getAccountCodesUsed();
} else if (entryType === "credit") {
return this.#credit.getAccountCodesUsed();
getCurrencyCode() {
return this.#code.value;
}
return []
/**
* Returns all the journal entries in the form.
*
* @param entryType {string|null} the entry type, either "debit" or "credit", or null for both
* @return {JournalEntrySubForm[]} all the journal entry sub-forms
*/
getEntries(entryType = null) {
const entries = []
for (const side of [this.#debit, this.#credit]) {
if (side !== null ) {
if (entryType === null || side.entryType === entryType) {
entries.push(...side.entries);
}
}
}
return entries;
}
/**
* Updates whether to enable the currency code selector
*
*/
updateCodeSelectorStatus() {
let isEnabled = true;
for (const entry of this.getEntries()) {
if (entry.getOriginalEntryId() !== null) {
isEnabled = false;
break;
}
}
this.#codeSelect.disabled = !isEnabled;
}
/**
@ -476,7 +575,7 @@ class DebitCreditSideSubForm {
* The journal entry sub-forms
* @type {JournalEntrySubForm[]}
*/
#entries;
entries;
/**
* The total
@ -506,7 +605,7 @@ class DebitCreditSideSubForm {
this.#error = document.getElementById(this.#prefix + "-error");
this.#entryList = document.getElementById(this.#prefix + "-list");
// noinspection JSValidateTypes
this.#entries = Array.from(document.getElementsByClassName(this.#prefix)).map((element) => new JournalEntrySubForm(this, element));
this.entries = Array.from(document.getElementsByClassName(this.#prefix)).map((element) => new JournalEntrySubForm(this, element));
this.#total = document.getElementById(this.#prefix + "-total");
this.#addEntryButton = document.getElementById(this.#prefix + "-add-entry");
this.#addEntryButton.onclick = () => {
@ -522,14 +621,14 @@ class DebitCreditSideSubForm {
* @returns {JournalEntrySubForm} the newly-added journal entry sub-form
*/
addJournalEntry() {
const newIndex = 1 + (this.#entries.length === 0? 0: Math.max(...this.#entries.map((entry) => entry.entryIndex)));
const newIndex = 1 + (this.entries.length === 0? 0: Math.max(...this.entries.map((entry) => entry.entryIndex)));
const html = this.currency.form.entryTemplate
.replaceAll("CURRENCY_INDEX", escapeHtml(String(this.#currencyIndex)))
.replaceAll("ENTRY_TYPE", escapeHtml(this.entryType))
.replaceAll("ENTRY_INDEX", escapeHtml(String(newIndex)));
this.#entryList.insertAdjacentHTML("beforeend", html);
const entry = new JournalEntrySubForm(this, document.getElementById(this.#prefix + "-" + String(newIndex)));
this.#entries.push(entry);
this.entries.push(entry);
this.#resetDeleteJournalEntryButtons();
this.#initializeDragAndDropReordering();
this.validate();
@ -542,9 +641,11 @@ class DebitCreditSideSubForm {
* @param entry {JournalEntrySubForm}
*/
deleteJournalEntry(entry) {
const index = this.#entries.indexOf(entry);
this.#entries.splice(index, 1);
const index = this.entries.indexOf(entry);
this.entries.splice(index, 1);
this.updateTotal();
this.currency.updateCodeSelectorStatus();
this.currency.form.updateMinDate();
this.#resetDeleteJournalEntryButtons();
}
@ -553,14 +654,18 @@ class DebitCreditSideSubForm {
*
*/
#resetDeleteJournalEntryButtons() {
if (this.#entries.length === 1) {
this.#entries[0].deleteButton.classList.add("d-none");
if (this.entries.length === 1) {
this.entries[0].deleteButton.classList.add("d-none");
} else {
for (const entry of this.entries) {
if (entry.isMatched) {
entry.deleteButton.classList.add("d-none");
} else {
for (const entry of this.#entries) {
entry.deleteButton.classList.remove("d-none");
}
}
}
}
/**
* Returns the total amount.
@ -569,9 +674,10 @@ class DebitCreditSideSubForm {
*/
getTotal() {
let total = new Decimal("0");
for (const entry of this.#entries) {
if (entry.amount.value !== "") {
total = total.plus(new Decimal(entry.amount.value));
for (const entry of this.entries) {
const amount = entry.getAmount();
if (amount !== null) {
total = total.plus(amount);
}
}
return total;
@ -582,17 +688,10 @@ class DebitCreditSideSubForm {
*
*/
updateTotal() {
let total = new Decimal("0");
for (const entry of this.#entries) {
if (entry.amount.value !== "") {
total = total.plus(new Decimal(entry.amount.value));
}
}
this.#total.innerText = formatDecimal(this.getTotal());
this.currency.validateBalance();
}
/**
* Initializes the drag and drop reordering on the currency sub-forms.
*
@ -600,22 +699,13 @@ class DebitCreditSideSubForm {
#initializeDragAndDropReordering() {
initializeDragAndDropReordering(this.#entryList, () => {
const entryId = Array.from(this.#entryList.children).map((entry) => entry.id);
this.#entries.sort((a, b) => entryId.indexOf(a.element.id) - entryId.indexOf(b.element.id));
for (let i = 0; i < this.#entries.length; i++) {
this.#entries[i].no.value = String(i + 1);
this.entries.sort((a, b) => entryId.indexOf(a.element.id) - entryId.indexOf(b.element.id));
for (let i = 0; i < this.entries.length; i++) {
this.entries[i].no.value = String(i + 1);
}
});
}
/**
* Returns the account codes used in the form.
*
* @return {string[]} the account codes used in the form
*/
getAccountCodesUsed() {
return this.#entries.filter((entry) => entry.getAccountCode() !== null).map((entry) => entry.getAccountCode());
}
/**
* Validates the form.
*
@ -624,7 +714,7 @@ class DebitCreditSideSubForm {
validate() {
let isValid = true;
isValid = this.#validateReal() && isValid;
for (const entry of this.#entries) {
for (const entry of this.entries) {
isValid = entry.validate() && isValid;
}
return isValid;
@ -636,7 +726,7 @@ class DebitCreditSideSubForm {
* @returns {boolean} true if valid, or false otherwise
*/
#validateReal() {
if (this.#entries.length === 0) {
if (this.entries.length === 0) {
this.#element.classList.add("is-invalid");
this.#error.innerText = A_("Please add some journal entries.");
return false;
@ -677,6 +767,12 @@ class JournalEntrySubForm {
*/
entryIndex;
/**
* Whether this is an original entry with offsets
* @type {boolean}
*/
isMatched;
/**
* The prefix of the HTML ID and class
* @type {string}
@ -725,11 +821,29 @@ class JournalEntrySubForm {
*/
#summaryText;
/**
* The ID of the original entry
* @type {HTMLInputElement}
*/
#originalEntryId;
/**
* The text of the original entry
* @type {HTMLDivElement}
*/
#originalEntryText;
/**
* The offset entries
* @type {HTMLInputElement}
*/
#offsets;
/**
* The amount
* @type {HTMLInputElement}
*/
amount;
#amount;
/**
* The text display of the amount
@ -754,6 +868,7 @@ class JournalEntrySubForm {
this.element = element;
this.entryType = element.dataset.entryType;
this.entryIndex = parseInt(element.dataset.entryIndex);
this.isMatched = element.classList.contains("accounting-matched-entry");
this.#prefix = "accounting-currency-" + element.dataset.currencyIndex + "-" + this.entryType + "-" + this.entryIndex;
this.#control = document.getElementById(this.#prefix + "-control");
this.#error = document.getElementById(this.#prefix + "-error");
@ -762,11 +877,14 @@ class JournalEntrySubForm {
this.#accountText = document.getElementById(this.#prefix + "-account-text");
this.#summary = document.getElementById(this.#prefix + "-summary");
this.#summaryText = document.getElementById(this.#prefix + "-summary-text");
this.amount = document.getElementById(this.#prefix + "-amount");
this.#originalEntryId = document.getElementById(this.#prefix + "-original-entry-id");
this.#originalEntryText = document.getElementById(this.#prefix + "-original-entry-text");
this.#offsets = document.getElementById(this.#prefix + "-offsets");
this.#amount = document.getElementById(this.#prefix + "-amount");
this.#amountText = document.getElementById(this.#prefix + "-amount-text");
this.deleteButton = document.getElementById(this.#prefix + "-delete");
this.#control.onclick = () => {
JournalEntryEditor.edit(this, this.#summary.value, this.#accountCode.value, this.#accountCode.dataset.text, this.amount.value);
JournalEntryEditor.edit(this, this.#originalEntryId.value, this.#originalEntryId.dataset.date, this.#originalEntryId.dataset.text, this.#summary.value, this.#accountCode.value, this.#accountCode.dataset.text, this.#amount.value, this.#amount.dataset.min);
};
this.deleteButton.onclick = () => {
this.element.parentElement.removeChild(this.element);
@ -774,6 +892,33 @@ class JournalEntrySubForm {
};
}
/**
* Returns whether the entry is an original entry.
*
* @return {boolean} true if the entry is an original entry, or false otherwise
*/
isOriginalEntry() {
return "isOriginalEntry" in this.element.dataset;
}
/**
* Returns the ID of the original entry.
*
* @return {string|null} the ID of the original entry
*/
getOriginalEntryId() {
return this.#originalEntryId.value === ""? null: this.#originalEntryId.value;
}
/**
* Returns the date of the original entry.
*
* @return {string|null} the date of the original entry
*/
getOriginalEntryDate() {
return this.#originalEntryId.dataset.date === ""? null: this.#originalEntryId.dataset.date;
}
/**
* Returns the account code.
*
@ -783,6 +928,15 @@ class JournalEntrySubForm {
return this.#accountCode.value === ""? null: this.#accountCode.value;
}
/**
* Returns the amount.
*
* @return {Decimal|null} the amount
*/
getAmount() {
return this.#amount.value === ""? null: new Decimal(this.#amount.value);
}
/**
* Validates the form.
*
@ -794,7 +948,7 @@ class JournalEntrySubForm {
this.#error.innerText = A_("Please select the account.");
return false;
}
if (this.amount.value === "") {
if (this.#amount.value === "") {
this.#control.classList.add("is-invalid");
this.#error.innerText = A_("Please fill in the amount.");
return false;
@ -807,21 +961,42 @@ class JournalEntrySubForm {
/**
* Stores the data into the journal entry sub-form.
*
* @param isOriginalEntry {boolean} true if this is an original entry, or false otherwise
* @param originalEntryId {string} the ID of the original entry
* @param originalEntryDate {string} the date of the original entry
* @param originalEntryText {string} the text of the original entry
* @param accountCode {string} the account code
* @param accountText {string} the account text
* @param summary {string} the summary
* @param amount {string} the amount
*/
save(accountCode, accountText, summary, amount) {
save(isOriginalEntry, originalEntryId, originalEntryDate, originalEntryText, accountCode, accountText, summary, amount) {
if (isOriginalEntry) {
this.#offsets.classList.remove("d-none");
} else {
this.#offsets.classList.add("d-none");
}
this.#originalEntryId.value = originalEntryId;
this.#originalEntryId.dataset.date = originalEntryDate;
this.#originalEntryId.dataset.text = originalEntryText;
if (originalEntryText === "") {
this.#originalEntryText.classList.add("d-none");
this.#originalEntryText.innerText = "";
} else {
this.#originalEntryText.classList.remove("d-none");
this.#originalEntryText.innerText = A_("Offset %(entry)s", {entry: originalEntryText});
}
this.#accountCode.value = accountCode;
this.#accountCode.dataset.text = accountText;
this.#accountText.innerText = accountText;
this.#summary.value = summary;
this.#summaryText.innerText = summary;
this.amount.value = amount;
this.#amount.value = amount;
this.#amountText.innerText = formatDecimal(new Decimal(amount));
this.validate();
this.side.updateTotal();
this.side.currency.updateCodeSelectorStatus();
this.side.currency.form.updateMinDate();
}
}

View File

@ -35,19 +35,9 @@ First written: 2023/2/26
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
{% for entry in currency.debit %}
<li class="list-group-item accounting-transaction-entry">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_format_amount }}</div>
</div>
</li>
{% endfor %}
{% with entries = currency.debit %}
{% include "accounting/transaction/include/detail-entries.html" %}
{% endwith %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div>

View File

@ -21,15 +21,16 @@ First written: 2023/2/25
#}
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}">
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
<div class="d-flex justify-content-between mt-2 mb-3">
<div class="form-floating accounting-currency-content">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code">
<select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code" {% if currency_code_is_locked %} disabled="disabled" {% endif %}>
{% for currency in accounting_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
</select>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ A_("Currency") }}</label>
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
</div>
@ -55,9 +56,17 @@ First written: 2023/2/25
account_text = entry_form.account_text,
summary_data = entry_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors,
original_entry_id_data = entry_form.original_entry_id.data|accounting_default,
original_entry_date = entry_form.original_entry_date|accounting_default,
original_entry_text = entry_form.original_entry_text|accounting_default,
is_original_entry = entry_form.is_original_entry,
offset_entries = entry_form.offsets,
offset_total = entry_form.offset_total|accounting_default("0"),
net_balance_data = entry_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"),
amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}

View File

@ -29,6 +29,7 @@ First written: 2023/2/25
currency_errors = currency_form.whole_form.errors,
currency_code_data = currency_form.code.data,
currency_code_errors = currency_form.code.errors,
currency_code_is_locked = currency_form.is_code_locked,
debit_forms = currency_form.debit,
debit_errors = currency_form.debit_errors,
debit_total = currency_form.form.debit_total|accounting_format_amount %}

View File

@ -37,7 +37,7 @@ First written: 2023/2/25
<ul id="accounting-account-selector-{{ entry_type }}-option-list" class="list-group accounting-selector-list">
{% for account in account_options %}
<li id="accounting-account-selector-{{ entry_type }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ entry_type }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<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 %} {% if account.is_offset_needed %} accounting-account-is-offset-needed {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
{{ account }}
</li>
{% endfor %}

View File

@ -0,0 +1,75 @@
{#
The Mia! Accounting Flask Project
detail-entries-item: The journal entries in the transaction detail
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/3/14
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
{% for entry in entries %}
<li class="list-group-item accounting-transaction-entry">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
{% if entry.original_entry %}
<div class="fst-italic small accounting-original-entry">
<a href="{{ url_for("accounting.transaction.detail", txn=entry.original_entry.transaction)|accounting_append_next }}">
{{ A_("Offset %(entry)s", entry=entry.original_entry) }}
</a>
</div>
{% endif %}
{% if entry.is_original_entry %}
<div class="fst-italic small accounting-offset-entries">
{% if entry.offsets %}
<div class="d-flex justify-content-between">
<div>{{ A_("Offsets") }}</div>
<ul class="ms-2 ps-0">
{% for offset in entry.offsets %}
<li>
<a href="{{ url_for("accounting.transaction.detail", txn=offset.transaction)|accounting_append_next }}">
{{ offset.transaction.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% if entry.balance %}
<div class="d-flex justify-content-between">
<div>{{ A_("Net balance") }}</div>
<div>{{ entry.balance|accounting_format_amount }}</div>
</div>
{% else %}
<div class="d-flex justify-content-between">
<div>{{ A_("Fully offset") }}</div>
</div>
{% endif %}
{% else %}
<div class="d-flex justify-content-between">
{{ A_("Unmatched") }}
</div>
{% endif %}
</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_format_amount }}</div>
</div>
</li>
{% endfor %}
{# </ul> For SonarQube not to complain about incorrect HTML #}

View File

@ -42,10 +42,17 @@ First written: 2023/2/26
</a>
{% if accounting_can_edit() %}
{% block to_transfer %}{% endblock %}
{% if obj.can_delete %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
{{ A_("Delete") }}
</button>
{% else %}
<button class="btn btn-secondary" type="button" disabled="disabled">
<i class="fa-solid fa-trash"></i>
{{ A_("Delete") }}
</button>
{% endif %}
{% endif %}
</div>
@ -57,7 +64,7 @@ First written: 2023/2/26
</div>
{% endif %}
{% if accounting_can_edit() %}
{% if accounting_can_edit() and obj.can_delete %}
<form action="{{ url_for("accounting.transaction.delete", txn=obj) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.next %}

View File

@ -20,19 +20,45 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
<li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-entry accounting-currency-{{ currency_index }}-{{ entry_type }}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}">
<li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-currency-{{ currency_index }}-{{ entry_type }} {% if offset_entries %} accounting-matched-entry {% endif %}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}" {% if is_original_entry %} data-is-original-entry="true" {% endif %}>
{% if entry_id %}
<input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}">
{% endif %}
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" value="{{ entry_index }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-original-entry-id" class="accounting-original-entry-id" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-original_entry_id" value="{{ original_entry_id_data }}" data-date="{{ original_entry_date }}" data-text="{{ original_entry_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" value="{{ summary_data }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}" data-min="{{ offset_total }}">
<div class="accounting-entry-content">
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-control" class="form-control clickable d-flex justify-content-between accounting-entry-control {% if entry_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-control" class="form-control clickable d-flex justify-content-between {% if entry_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-text" class="small">{{ account_text }}</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary-text">{{ summary_data }}</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-original-entry-text" class="fst-italic small accounting-original-entry {% if not original_entry_text %} d-none {% endif %}">
{% if original_entry_text %}{{ A_("Offset %(entry)s", entry=original_entry_text) }}{% endif %}
</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-offsets" class="fst-italic small accounting-offset-entries {% if not is_original_entry %} d-none {% endif %}">
{% if offset_entries %}
<div class="d-flex justify-content-between {% if not offset_entries %} d-none {% endif %}">
<div>{{ A_("Offsets") }}</div>
<ul class="ms-2 ps-0">
{% for offset in offset_entries %}
<li>{{ offset.transaction.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}</li>
{% endfor %}
</ul>
</div>
{% if net_balance_data == 0 %}
<div>{{ A_("Fully offset") }}</div>
{% else %}
<div class="d-flex justify-content-between">
<div>{{ A_("Net balance") }}</div>
<div>{{ net_balance_text }}</div>
</div>
{% endif %}
{% else %}
{{ A_("Unmatched") }}
{% endif %}
</div>
</div>
<div><span id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount-text" class="badge rounded-pill bg-primary">{{ amount_text }}</span></div>
</div>
@ -40,7 +66,7 @@ First written: 2023/2/25
</div>
<div>
<button id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_entry_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" data-same-class="accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry">
<button id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_entry_form or offset_entries %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}">
<i class="fas fa-minus"></i>
</button>
</div>

View File

@ -26,6 +26,7 @@ First written: 2023/2/26
<script src="{{ url_for("accounting.static", filename="js/transaction-form.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/journal-entry-editor.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/original-entry-selector.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/summary-editor.js") }}"></script>
{% endblock %}
@ -45,7 +46,7 @@ First written: 2023/2/26
{% endif %}
<div class="form-floating mb-3">
<input id="accounting-date" class="form-control {% if form.date.errors %} is-invalid {% endif %}" type="date" name="date" value="{{ form.date.data|accounting_default }}" placeholder=" " required="required">
<input id="accounting-date" class="form-control {% if form.date.errors %} is-invalid {% endif %}" type="date" name="date" value="{{ form.date.data|accounting_default }}" max="{{ form.max_date|accounting_default }}" min="{{ form.min_date|accounting_default }}" placeholder=" " required="required">
<label class="form-label" for="accounting-date">{{ A_("Date") }}</label>
<div id="accounting-date-error" class="invalid-feedback">{% if form.date.errors %}{{ form.date.errors[0] }}{% endif %}</div>
</div>
@ -89,5 +90,6 @@ First written: 2023/2/26
{% include "accounting/transaction/include/journal-entry-editor-modal.html" %}
{% block form_modals %}{% endblock %}
{% include "accounting/transaction/include/original-entry-selector-modal.html" %}
{% endblock %}

View File

@ -28,6 +28,22 @@ First written: 2023/2/25
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div id="accounting-entry-editor-original-entry-container" class="d-flex justify-content-between mb-3">
<div class="accounting-entry-editor-original-entry-content">
<div id="accounting-entry-editor-original-entry-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="#accounting-original-entry-selector-modal">
<label class="form-label" for="accounting-entry-editor-original-entry">{{ A_("Original Entry") }}</label>
<div id="accounting-entry-editor-original-entry" data-id="" data-date="" data-text=""></div>
</div>
<div id="accounting-entry-editor-original-entry-error" class="invalid-feedback"></div>
</div>
<div>
<button id="accounting-entry-editor-original-entry-delete" class="btn btn-danger rounded-circle" type="button">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="mb-3">
<div id="accounting-entry-editor-summary-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
<label class="form-label" for="accounting-entry-editor-summary">{{ A_("Summary") }}</label>
@ -45,7 +61,7 @@ First written: 2023/2/25
</div>
<div class="form-floating mb-3">
<input id="accounting-entry-editor-amount" class="form-control" type="number" value="" min="0.01" max="" step="0.01" placeholder=" " required="required">
<input id="accounting-entry-editor-amount" class="form-control" type="number" value="" min="0" max="" step="0.01" placeholder=" " required="required">
<label for="accounting-entry-editor-amount">{{ A_("Amount") }}</label>
<div id="accounting-entry-editor-amount-error" class="invalid-feedback"></div>
</div>

View File

@ -0,0 +1,56 @@
{#
The Mia! Accounting Flask Project
original-entry-selector-modal.html: The modal of the original entry 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-original-entry-selector-modal" class="modal fade" data-currency-code="" data-entry-type="" tabindex="-1" aria-labelledby="accounting-original-entry-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-original-entry-selector-modal-label">{{ A_("Select Original Entry") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-original-entry-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-original-entry-selector-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-original-entry-selector-option-list" class="list-group accounting-selector-list">
{% for entry in form.original_entry_options %}
<li id="accounting-original-entry-selector-option-{{ entry.id }}" class="list-group-item d-flex justify-content-between accounting-clickable accounting-original-entry-selector-option" data-id="{{ entry.id }}" data-date="{{ entry.transaction.date }}" data-entry-type="{{ "debit" if entry.is_debit else "credit" }}" data-currency-code="{{ entry.currency.code }}" data-account-code="{{ entry.account_code }}" data-account-text="{{ entry.account }}" data-summary="{{ entry.summary|accounting_default }}" data-net-balance="{{ entry.net_balance|accounting_txn_format_amount_input }}" data-text="{{ entry }}" data-query-values="{{ entry.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<div>{{ entry.transaction.date|accounting_format_date }} {{ entry.summary|accounting_default }}</div>
<div>
<span class="badge bg-primary rounded-pill">
<span id="accounting-original-entry-selector-option-{{ entry.id }}-net-balance">{{ entry.net_balance|accounting_format_amount }}</span>
/ {{ entry.amount|accounting_format_amount }}
</span>
</div>
</li>
{% endfor %}
</ul>
<p id="accounting-original-entry-selector-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
</div>
</div>
</div>

View File

@ -30,8 +30,11 @@ First written: 2023/2/28
<button class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<div class="d-flex justify-content-between mb-3">
<input id="accounting-summary-editor-{{ summary_editor.type }}-summary" class="form-control" type="text" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-modal-label">
<button id="accounting-summary-editor-{{ summary_editor.type }}-offset" class="btn btn-primary ms-2" type="button" data-bs-toggle="modal" data-bs-target="#accounting-original-entry-selector-modal">
{{ A_("Offset...") }}
</button>
</div>
{# Tab navigation #}
@ -174,7 +177,7 @@ First written: 2023/2/28
{# The suggested accounts #}
<div class="mt-3">
{% for account in summary_editor.accounts %}
<button class="btn btn-outline-primary d-none accounting-summary-editor-{{ summary_editor.type }}-account" type="button" data-code="{{ account.code }}" data-text="{{ account }}">
<button class="btn btn-outline-primary d-none accounting-summary-editor-{{ summary_editor.type }}-account {% if account.is_offset_needed %} accounting-account-is-offset-needed {% endif %}" type="button" data-code="{{ account.code }}" data-text="{{ account }}">
{{ account }}
</button>
{% endfor %}

View File

@ -35,19 +35,9 @@ First written: 2023/2/26
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
{% for entry in currency.credit %}
<li class="list-group-item accounting-transaction-entry">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_format_amount }}</div>
</div>
</li>
{% endfor %}
{% with entries = currency.credit %}
{% include "accounting/transaction/include/detail-entries.html" %}
{% endwith %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div>

View File

@ -21,15 +21,16 @@ First written: 2023/2/25
#}
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}">
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
<div class="d-flex justify-content-between mt-2 mb-3">
<div class="form-floating accounting-currency-content">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code">
<select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code" {% if currency_code_is_locked %} disabled="disabled" {% endif %}>
{% for currency in accounting_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
</select>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ A_("Currency") }}</label>
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
</div>
@ -55,9 +56,17 @@ First written: 2023/2/25
account_text = entry_form.account_text,
summary_data = entry_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors,
original_entry_id_data = entry_form.original_entry_id.data|accounting_default,
original_entry_date = entry_form.original_entry_date|accounting_default,
original_entry_text = entry_form.original_entry_text|accounting_default,
is_original_entry = entry_form.is_original_entry,
offset_entries = entry_form.offsets,
offset_total = entry_form.offset_total|accounting_default("0"),
net_balance_data = entry_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"),
amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}

View File

@ -29,6 +29,7 @@ First written: 2023/2/25
currency_errors = currency_form.whole_form.errors,
currency_code_data = currency_form.code.data,
currency_code_errors = currency_form.code.errors,
currency_code_is_locked = currency_form.is_code_locked,
credit_forms = currency_form.credit,
credit_errors = currency_form.credit_errors,
credit_total = currency_form.form.credit_total|accounting_format_amount %}

View File

@ -31,19 +31,9 @@ First written: 2023/2/26
<div class="col-sm-6 mb-2">
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Debit") }}</li>
{% for entry in currency.debit %}
<li class="list-group-item accounting-transaction-entry">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_format_amount }}</div>
</div>
</li>
{% endfor %}
{% with entries = currency.debit %}
{% include "accounting/transaction/include/detail-entries.html" %}
{% endwith %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div>
@ -57,19 +47,9 @@ First written: 2023/2/26
<div class="col-sm-6 mb-2">
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Credit") }}</li>
{% for entry in currency.credit %}
<li class="list-group-item accounting-transaction-entry">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_format_amount }}</div>
</div>
</li>
{% endfor %}
{% with entries = currency.credit %}
{% include "accounting/transaction/include/detail-entries.html" %}
{% endwith %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div>

View File

@ -21,15 +21,16 @@ First written: 2023/2/25
#}
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}">
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
<div class="d-flex justify-content-between mt-2 mb-3">
<div class="form-floating accounting-currency-content">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code">
<select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" {% if currency_code_is_locked %} disabled="disabled" {% endif %}>
{% for currency in accounting_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
</select>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ A_("Currency") }}</label>
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
</div>
@ -57,9 +58,17 @@ First written: 2023/2/25
account_text = entry_form.account_text,
summary_data = entry_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors,
original_entry_id_data = entry_form.original_entry_id.data|accounting_default,
original_entry_date = entry_form.original_entry_date|accounting_default,
original_entry_text = entry_form.original_entry_text|accounting_default,
is_original_entry = entry_form.is_original_entry,
offset_entries = entry_form.offsets,
offset_total = entry_form.offset_total|accounting_default,
net_balance_data = entry_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"),
amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}
@ -97,9 +106,17 @@ First written: 2023/2/25
account_text = entry_form.account_text,
summary_data = entry_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors,
original_entry_id_data = entry_form.original_entry_id.data|accounting_default,
original_entry_date = entry_form.original_entry_date|accounting_default,
original_entry_text = entry_form.original_entry_text|accounting_default,
is_original_entry = entry_form.is_original_entry,
offset_entries = entry_form.offsets,
offset_total = entry_form.offset_total|accounting_default("0"),
net_balance_data = entry_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"),
amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}

View File

@ -29,6 +29,7 @@ First written: 2023/2/25
currency_errors = currency_form.whole_form.errors,
currency_code_data = currency_form.code.data,
currency_code_errors = currency_form.code.errors,
currency_code_is_locked = currency_form.is_code_locked,
debit_forms = currency_form.debit,
debit_errors = currency_form.debit_errors,
debit_total = currency_form.form.debit_total|accounting_format_amount,

View File

@ -20,10 +20,10 @@
from datetime import date
from flask import abort
from sqlalchemy.orm import selectinload
from werkzeug.routing import BaseConverter
from accounting import db
from accounting.models import Transaction
from accounting.models import Transaction, JournalEntry
from accounting.utils.txn_types import TransactionType
@ -37,7 +37,13 @@ class TransactionConverter(BaseConverter):
:param value: The transaction ID.
:return: The corresponding transaction.
"""
transaction: Transaction | None = db.session.get(Transaction, value)
transaction: Transaction | None = Transaction.query\
.join(JournalEntry)\
.filter(Transaction.id == value)\
.options(selectinload(Transaction.entries)
.selectinload(JournalEntry.offsets)
.selectinload(JournalEntry.transaction))\
.first()
if transaction is None:
abort(404)
return transaction

View File

@ -19,6 +19,7 @@
"""
from decimal import Decimal
import sqlalchemy as sa
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError, FieldList, IntegerField, \
@ -27,9 +28,11 @@ from wtforms.validators import DataRequired
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency
from accounting.models import Currency, JournalEntry
from accounting.transaction.utils.offset_alias import offset_alias
from accounting.utils.cast import be
from accounting.utils.strip_text import strip_text
from .journal_entry import CreditEntryForm, DebitEntryForm
from .journal_entry import JournalEntryForm, CreditEntryForm, DebitEntryForm
CURRENCY_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the currency."))
@ -47,6 +50,50 @@ class CurrencyExists:
"The currency does not exist."))
class SameCurrencyAsOriginalEntries:
"""The validator to check if the currency is the same as the original
entries."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CurrencyForm)
if field.data is None:
return
original_entry_id: set[int] = {x.original_entry_id.data
for x in form.entries
if x.original_entry_id.data is not None}
if len(original_entry_id) == 0:
return
original_entry_currency_codes: set[str] = set(db.session.scalars(
sa.select(JournalEntry.currency_code)
.filter(JournalEntry.id.in_(original_entry_id))).all())
for currency_code in original_entry_currency_codes:
if field.data != currency_code:
raise ValidationError(lazy_gettext(
"The currency must be the same as the original entry."))
class KeepCurrencyWhenHavingOffset:
"""The validator to check if the currency is the same when there is
offset."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CurrencyForm)
if field.data is None:
return
offset: sa.Alias = offset_alias()
original_entries: list[JournalEntry] = JournalEntry.query\
.join(offset, be(JournalEntry.id == offset.c.original_entry_id),
isouter=True)\
.filter(JournalEntry.id.in_({x.eid.data for x in form.entries
if x.eid.data is not None}))\
.group_by(JournalEntry.id, JournalEntry.currency_code)\
.having(sa.func.count(offset.c.id) > 0).all()
for original_entry in original_entries:
if original_entry.currency_code != field.data:
raise ValidationError(lazy_gettext(
"The currency must not be changed when there is offset."))
class NeedSomeJournalEntries:
"""The validator to check if there is any journal entry sub-form."""
@ -78,6 +125,41 @@ class CurrencyForm(FlaskForm):
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
@property
def entries(self) -> list[JournalEntryForm]:
"""Returns the journal entry sub-forms.
:return: The journal entry sub-forms.
"""
entry_forms: list[JournalEntryForm] = []
if isinstance(self, IncomeCurrencyForm):
entry_forms.extend([x.form for x in self.credit])
elif isinstance(self, ExpenseCurrencyForm):
entry_forms.extend([x.form for x in self.debit])
elif isinstance(self, TransferCurrencyForm):
entry_forms.extend([x.form for x in self.debit])
entry_forms.extend([x.form for x in self.credit])
return entry_forms
@property
def is_code_locked(self) -> bool:
"""Returns whether the currency code should not be changed.
:return: True if the currency code should not be changed, or False
otherwise
"""
entry_forms: list[JournalEntryForm] = self.entries
original_entry_id: set[int] \
= {x.original_entry_id.data for x in entry_forms
if x.original_entry_id.data is not None}
if len(original_entry_id) > 0:
return True
entry_id: set[int] = {x.eid.data for x in entry_forms
if x.eid.data is not None}
select: sa.Select = sa.select(sa.func.count(JournalEntry.id))\
.filter(JournalEntry.original_entry_id.in_(entry_id))
return db.session.scalar(select) > 0
class IncomeCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash income transaction."""
@ -86,7 +168,9 @@ class IncomeCurrencyForm(CurrencyForm):
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists()])
CurrencyExists(),
SameCurrencyAsOriginalEntries(),
KeepCurrencyWhenHavingOffset()])
"""The currency code."""
credit = FieldList(FormField(CreditEntryForm),
validators=[NeedSomeJournalEntries()])
@ -121,7 +205,9 @@ class ExpenseCurrencyForm(CurrencyForm):
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists()])
CurrencyExists(),
SameCurrencyAsOriginalEntries(),
KeepCurrencyWhenHavingOffset()])
"""The currency code."""
debit = FieldList(FormField(DebitEntryForm),
validators=[NeedSomeJournalEntries()])
@ -156,7 +242,9 @@ class TransferCurrencyForm(CurrencyForm):
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists()])
CurrencyExists(),
SameCurrencyAsOriginalEntries(),
KeepCurrencyWhenHavingOffset()])
"""The currency code."""
debit = FieldList(FormField(DebitEntryForm),
validators=[NeedSomeJournalEntries()])

View File

@ -18,14 +18,21 @@
"""
import re
from datetime import date
from decimal import Decimal
import sqlalchemy as sa
from flask_babel import LazyString
from flask_wtf import FlaskForm
from sqlalchemy.orm import selectinload
from wtforms import StringField, ValidationError, DecimalField, IntegerField
from wtforms.validators import DataRequired
from wtforms.validators import DataRequired, Optional
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Account, JournalEntry
from accounting.template_filters import format_amount
from accounting.utils.cast import be
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk
@ -35,6 +42,66 @@ ACCOUNT_REQUIRED: DataRequired = DataRequired(
"""The validator to check if the account code is empty."""
class OriginalEntryExists:
"""The validator to check if the original entry exists."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
if db.session.get(JournalEntry, field.data) is None:
raise ValidationError(lazy_gettext(
"The original entry does not exist."))
class OriginalEntryOppositeSide:
"""The validator to check if the original entry is on the opposite side."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
original_entry: JournalEntry | None \
= db.session.get(JournalEntry, field.data)
if original_entry is None:
return
if isinstance(form, CreditEntryForm) and original_entry.is_debit:
return
if isinstance(form, DebitEntryForm) and not original_entry.is_debit:
return
raise ValidationError(lazy_gettext(
"The original entry is on the same side."))
class OriginalEntryNeedOffset:
"""The validator to check if the original entry needs offset."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
original_entry: JournalEntry | None \
= db.session.get(JournalEntry, field.data)
if original_entry is None:
return
if not original_entry.account.is_offset_needed:
raise ValidationError(lazy_gettext(
"The original entry does not need offset."))
class OriginalEntryNotOffset:
"""The validator to check if the original entry is not itself an offset
entry."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
original_entry: JournalEntry | None \
= db.session.get(JournalEntry, field.data)
if original_entry is None:
return
if original_entry.original_entry_id is not None:
raise ValidationError(lazy_gettext(
"The original entry cannot be an offset entry."))
class AccountExists:
"""The validator to check if the account exists."""
@ -74,6 +141,72 @@ class IsCreditAccount:
"This account is not for credit entries."))
class SameAccountAsOriginalEntry:
"""The validator to check if the account is the same as the original
entry."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, JournalEntryForm)
if field.data is None or form.original_entry_id.data is None:
return
original_entry: JournalEntry | None \
= db.session.get(JournalEntry, form.original_entry_id.data)
if original_entry is None:
return
if field.data != original_entry.account_code:
raise ValidationError(lazy_gettext(
"The account must be the same as the original entry."))
class KeepAccountWhenHavingOffset:
"""The validator to check if the account is the same when having offset."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, JournalEntryForm)
if field.data is None or form.eid.data is None:
return
entry: JournalEntry | None = db.session.query(JournalEntry)\
.filter(JournalEntry.id == form.eid.data)\
.options(selectinload(JournalEntry.offsets)).first()
if entry is None or len(entry.offsets) == 0:
return
if field.data != entry.account_code:
raise ValidationError(lazy_gettext(
"The account must not be changed when there is offset."))
class NotStartPayableFromDebit:
"""The validator to check that a payable journal entry does not start from
the debit side."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, DebitEntryForm)
if field.data is None \
or field.data[0] != "2" \
or form.original_entry_id.data is not None:
return
account: Account | None = Account.find_by_code(field.data)
if account is not None and account.is_offset_needed:
raise ValidationError(lazy_gettext(
"A payable entry cannot start from the debit side."))
class NotStartReceivableFromCredit:
"""The validator to check that a receivable journal entry does not start
from the credit side."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CreditEntryForm)
if field.data is None \
or field.data[0] != "1" \
or form.original_entry_id.data is not None:
return
account: Account | None = Account.find_by_code(field.data)
if account is not None and account.is_offset_needed:
raise ValidationError(lazy_gettext(
"A receivable entry cannot start from the credit side."))
class PositiveAmount:
"""The validator to check if the amount is positive."""
@ -85,17 +218,86 @@ class PositiveAmount:
"Please fill in a positive amount."))
class NotExceedingOriginalEntryNetBalance:
"""The validator to check if the amount exceeds the net balance of the
original entry."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
assert isinstance(form, JournalEntryForm)
if field.data is None or form.original_entry_id.data is None:
return
original_entry: JournalEntry | None \
= db.session.get(JournalEntry, form.original_entry_id.data)
if original_entry is None:
return
is_debit: bool = isinstance(form, DebitEntryForm)
existing_entry_id: set[int] = set()
if form.txn_form.obj is not None:
existing_entry_id = {x.id for x in form.txn_form.obj.entries}
offset_total_func: sa.Function = sa.func.sum(sa.case(
(be(JournalEntry.is_debit == is_debit), JournalEntry.amount),
else_=-JournalEntry.amount))
offset_total_but_form: Decimal | None = db.session.scalar(
sa.select(offset_total_func)
.filter(be(JournalEntry.original_entry_id == original_entry.id),
JournalEntry.id.not_in(existing_entry_id)))
if offset_total_but_form is None:
offset_total_but_form = Decimal("0")
offset_total_on_form: Decimal = sum(
[x.amount.data for x in form.txn_form.entries
if x.original_entry_id.data == original_entry.id
and x.amount != field and x.amount.data is not None])
net_balance: Decimal = original_entry.amount - offset_total_but_form \
- offset_total_on_form
if field.data > net_balance:
raise ValidationError(lazy_gettext(
"The amount must not exceed the net balance %(balance)s of the"
" original entry.", balance=format_amount(net_balance)))
class NotLessThanOffsetTotal:
"""The validator to check if the amount is less than the offset total."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
assert isinstance(form, JournalEntryForm)
if field.data is None or form.eid.data is None:
return
is_debit: bool = isinstance(form, DebitEntryForm)
select_offset_total: sa.Select = sa.select(sa.func.sum(sa.case(
(JournalEntry.is_debit != is_debit, JournalEntry.amount),
else_=-JournalEntry.amount)))\
.filter(be(JournalEntry.original_entry_id == form.eid.data))
offset_total: Decimal | None = db.session.scalar(select_offset_total)
if offset_total is not None and field.data < offset_total:
raise ValidationError(lazy_gettext(
"The amount must not be less than the offset total %(total)s.",
total=format_amount(offset_total)))
class JournalEntryForm(FlaskForm):
"""The base form to create or edit a journal entry."""
eid = IntegerField()
"""The existing journal entry ID."""
no = IntegerField()
"""The order in the currency."""
original_entry_id = IntegerField()
"""The Id of the original entry."""
account_code = StringField()
"""The account code."""
amount = DecimalField()
"""The amount."""
def __init__(self, *args, **kwargs):
"""Constructs a base transaction form.
:param args: The arguments.
:param kwargs: The keyword arguments.
"""
super().__init__(*args, **kwargs)
from .transaction import TransactionForm
self.txn_form: TransactionForm | None = None
"""The source transaction form."""
@property
def account_text(self) -> str:
"""Returns the text representation of the account.
@ -109,6 +311,124 @@ class JournalEntryForm(FlaskForm):
return ""
return str(account)
@property
def __original_entry(self) -> JournalEntry | None:
"""Returns the original entry.
:return: The original entry.
"""
if not hasattr(self, "____original_entry"):
def get_entry() -> JournalEntry | None:
if self.original_entry_id.data is None:
return None
return db.session.get(JournalEntry,
self.original_entry_id.data)
setattr(self, "____original_entry", get_entry())
return getattr(self, "____original_entry")
@property
def original_entry_date(self) -> date | None:
"""Returns the text representation of the original entry.
:return: The text representation of the original entry.
"""
return None if self.__original_entry is None \
else self.__original_entry.transaction.date
@property
def original_entry_text(self) -> str | None:
"""Returns the text representation of the original entry.
:return: The text representation of the original entry.
"""
return None if self.__original_entry is None \
else str(self.__original_entry)
@property
def __entry(self) -> JournalEntry | None:
"""Returns the journal entry.
:return: The journal entry.
"""
if not hasattr(self, "____entry"):
def get_entry() -> JournalEntry | None:
if self.eid.data is None:
return None
return JournalEntry.query\
.filter(JournalEntry.id == self.eid.data)\
.options(selectinload(JournalEntry.transaction),
selectinload(JournalEntry.account),
selectinload(JournalEntry.offsets)
.selectinload(JournalEntry.transaction))\
.first()
setattr(self, "____entry", get_entry())
return getattr(self, "____entry")
@property
def is_original_entry(self) -> bool:
"""Returns whether the entry is an original entry.
:return: True if the entry is an original entry, or False otherwise.
"""
if self.account_code.data is None:
return False
if self.account_code.data[0] == "1":
if isinstance(self, CreditEntryForm):
return False
elif self.account_code.data[0] == "2":
if isinstance(self, DebitEntryForm):
return False
else:
return False
account: Account | None = Account.find_by_code(self.account_code.data)
return account is not None and account.is_offset_needed
@property
def offsets(self) -> list[JournalEntry]:
"""Returns the offsets.
:return: The offsets.
"""
if not hasattr(self, "__offsets"):
def get_offsets() -> list[JournalEntry]:
if not self.is_original_entry or self.eid.data is None:
return []
return JournalEntry.query\
.filter(JournalEntry.original_entry_id == self.eid.data)\
.options(selectinload(JournalEntry.transaction),
selectinload(JournalEntry.account),
selectinload(JournalEntry.offsets)
.selectinload(JournalEntry.transaction)).all()
setattr(self, "__offsets", get_offsets())
return getattr(self, "__offsets")
@property
def offset_total(self) -> Decimal | None:
"""Returns the total amount of the offsets.
:return: The total amount of the offsets.
"""
if not hasattr(self, "__offset_total"):
def get_offset_total():
if not self.is_original_entry or self.eid.data is None:
return None
is_debit: bool = isinstance(self, DebitEntryForm)
return sum([x.amount if x.is_debit != is_debit else -x.amount
for x in self.offsets])
setattr(self, "__offset_total", get_offset_total())
return getattr(self, "__offset_total")
@property
def net_balance(self) -> Decimal | None:
"""Returns the net balance.
:return: The net balance.
"""
if not self.is_original_entry or self.eid.data is None \
or self.amount.data is None:
return None
return self.amount.data - self.offset_total
@property
def all_errors(self) -> list[str | LazyString]:
"""Returns all the errors of the form.
@ -128,15 +448,30 @@ class DebitEntryForm(JournalEntryForm):
"""The existing journal entry ID."""
no = IntegerField()
"""The order in the currency."""
original_entry_id = IntegerField(
validators=[Optional(),
OriginalEntryExists(),
OriginalEntryOppositeSide(),
OriginalEntryNeedOffset(),
OriginalEntryNotOffset()])
"""The Id of the original entry."""
account_code = StringField(
filters=[strip_text],
validators=[ACCOUNT_REQUIRED,
AccountExists(),
IsDebitAccount()])
IsDebitAccount(),
SameAccountAsOriginalEntry(),
KeepAccountWhenHavingOffset(),
NotStartPayableFromDebit()])
"""The account code."""
offset_original_entry_id = IntegerField()
"""The Id of the original entry."""
summary = StringField(filters=[strip_text])
"""The summary."""
amount = DecimalField(validators=[PositiveAmount()])
amount = DecimalField(
validators=[PositiveAmount(),
NotExceedingOriginalEntryNetBalance(),
NotLessThanOffsetTotal()])
"""The amount."""
def populate_obj(self, obj: JournalEntry) -> None:
@ -148,6 +483,7 @@ class DebitEntryForm(JournalEntryForm):
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntry)
obj.original_entry_id = self.original_entry_id.data
obj.account_id = Account.find_by_code(self.account_code.data).id
obj.summary = self.summary.data
obj.is_debit = True
@ -164,15 +500,28 @@ class CreditEntryForm(JournalEntryForm):
"""The existing journal entry ID."""
no = IntegerField()
"""The order in the currency."""
original_entry_id = IntegerField(
validators=[Optional(),
OriginalEntryExists(),
OriginalEntryOppositeSide(),
OriginalEntryNeedOffset(),
OriginalEntryNotOffset()])
"""The Id of the original entry."""
account_code = StringField(
filters=[strip_text],
validators=[ACCOUNT_REQUIRED,
AccountExists(),
IsCreditAccount()])
IsCreditAccount(),
SameAccountAsOriginalEntry(),
KeepAccountWhenHavingOffset(),
NotStartReceivableFromCredit()])
"""The account code."""
summary = StringField(filters=[strip_text])
"""The summary."""
amount = DecimalField(validators=[PositiveAmount()])
amount = DecimalField(
validators=[PositiveAmount(),
NotExceedingOriginalEntryNetBalance(),
NotLessThanOffsetTotal()])
"""The amount."""
def populate_obj(self, obj: JournalEntry) -> None:
@ -184,6 +533,7 @@ class CreditEntryForm(JournalEntryForm):
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntry)
obj.original_entry_id = self.original_entry_id.data
obj.account_id = Account.find_by_code(self.account_code.data).id
obj.summary = self.summary.data
obj.is_debit = False

View File

@ -19,13 +19,14 @@
"""
from datetime import date
import sqlalchemy as sa
from flask import request
from accounting import db
from accounting.models import Transaction
def sort_transactions_in(txn_date: date, exclude: int) -> None:
def sort_transactions_in(txn_date: date, exclude: int | None = None) -> None:
"""Sorts the transactions under a date after changing the date or deleting
a transaction.
@ -33,9 +34,11 @@ def sort_transactions_in(txn_date: date, exclude: int) -> None:
:param exclude: The transaction ID to exclude.
:return: None.
"""
conditions: list[sa.BinaryExpression] = [Transaction.date == txn_date]
if exclude is not None:
conditions.append(Transaction.id != exclude)
transactions: list[Transaction] = Transaction.query\
.filter(Transaction.date == txn_date,
Transaction.id != exclude)\
.filter(*conditions)\
.order_by(Transaction.no).all()
for i in range(len(transactions)):
if transactions[i].no != i + 1:

View File

@ -17,14 +17,15 @@
"""The transaction forms for the transaction management.
"""
import datetime as dt
import typing as t
from abc import ABC, abstractmethod
import sqlalchemy as sa
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import DateField, FieldList, FormField, \
TextAreaField
from wtforms import DateField, FieldList, FormField, TextAreaField, \
BooleanField
from wtforms.validators import DataRequired, ValidationError
from accounting import db
@ -32,6 +33,8 @@ from accounting.locale import lazy_gettext
from accounting.models import Transaction, Account, JournalEntry, \
TransactionCurrency
from accounting.transaction.utils.account_option import AccountOption
from accounting.transaction.utils.original_entries import \
get_selectable_original_entries
from accounting.transaction.utils.summary_editor import SummaryEditor
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_multiline_text
@ -46,6 +49,37 @@ DATE_REQUIRED: DataRequired = DataRequired(
"""The validator to check if the date is empty."""
class NotBeforeOriginalEntries:
"""The validator to check if the date is not before the original
entries."""
def __call__(self, form: FlaskForm, field: DateField) -> None:
assert isinstance(form, TransactionForm)
if field.data is None:
return
min_date: dt.date | None = form.min_date
if min_date is None:
return
if field.data < min_date:
raise ValidationError(lazy_gettext(
"The date cannot be earlier than the original entries."))
class NotAfterOffsetEntries:
"""The validator to check if the date is not after the offset entries."""
def __call__(self, form: FlaskForm, field: DateField) -> None:
assert isinstance(form, TransactionForm)
if field.data is None:
return
max_date: dt.date | None = form.max_date
if max_date is None:
return
if field.data > max_date:
raise ValidationError(lazy_gettext(
"The date cannot be later than the offset entries."))
class NeedSomeCurrencies:
"""The validator to check if there is any currency sub-form."""
@ -54,6 +88,23 @@ class NeedSomeCurrencies:
raise ValidationError(lazy_gettext("Please add some currencies."))
class CannotDeleteOriginalEntriesWithOffset:
"""The validator to check the original entries with offset."""
def __call__(self, form: FlaskForm, field: FieldList) -> None:
assert isinstance(form, TransactionForm)
if form.obj is None:
return
existing_matched_original_entry_id: set[int] \
= {x.id for x in form.obj.entries if len(x.offsets) > 0}
entry_id_in_form: set[int] \
= {x.eid.data for x in form.entries if x.eid.data is not None}
for entry_id in existing_matched_original_entry_id:
if entry_id not in entry_id_in_form:
raise ValidationError(lazy_gettext(
"Journal entries with offset cannot be deleted."))
class TransactionForm(FlaskForm):
"""The base form to create or edit a transaction."""
date = DateField()
@ -76,6 +127,19 @@ class TransactionForm(FlaskForm):
"""The journal entry collector. The default is the base abstract
collector only to provide the correct type. The subclass forms should
provide their own collectors."""
self.obj: Transaction | None = kwargs.get("obj")
"""The transaction, when editing an existing one."""
self._is_payable_needed: bool = False
"""Whether we need the payable original entries."""
self._is_receivable_needed: bool = False
"""Whether we need the receivable original entries."""
self.__original_entry_options: list[JournalEntry] | None = None
"""The options of the original entries."""
self.__net_balance_exceeded: dict[int, LazyString] | None = None
"""The original entries whose net balances were exceeded by the
amounts in the journal entry sub-forms."""
for entry in self.entries:
entry.txn_form = self
def populate_obj(self, obj: Transaction) -> None:
"""Populates the form data into a transaction object.
@ -86,6 +150,7 @@ class TransactionForm(FlaskForm):
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(Transaction)
self.date: DateField
self.__set_date(obj, self.date.data)
obj.note = self.note.data
@ -107,8 +172,18 @@ class TransactionForm(FlaskForm):
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk
@staticmethod
def __set_date(obj: Transaction, new_date: date) -> None:
@property
def entries(self) -> list[JournalEntryForm]:
"""Collects and returns the journal entry sub-forms.
:return: The journal entry sub-forms.
"""
entries: list[JournalEntryForm] = []
for currency in self.currencies:
entries.extend(currency.entries)
return entries
def __set_date(self, obj: Transaction, new_date: dt.date) -> None:
"""Sets the transaction date and number.
:param obj: The transaction object.
@ -118,6 +193,18 @@ class TransactionForm(FlaskForm):
if obj.date is None or obj.date != new_date:
if obj.date is not None:
sort_transactions_in(obj.date, obj.id)
if self.max_date is not None and new_date == self.max_date:
db_min_no: int | None = db.session.scalar(
sa.select(sa.func.min(Transaction.no))
.filter(Transaction.date == new_date))
if db_min_no is None:
obj.date = new_date
obj.no = 1
else:
obj.date = new_date
obj.no = db_min_no - 1
sort_transactions_in(new_date)
else:
sort_transactions_in(new_date, obj.id)
count: int = Transaction.query\
.filter(Transaction.date == new_date).count()
@ -131,7 +218,8 @@ class TransactionForm(FlaskForm):
:return: The selectable debit accounts.
"""
accounts: list[AccountOption] \
= [AccountOption(x) for x in Account.debit()]
= [AccountOption(x) for x in Account.debit()
if not (x.code[0] == "2" and x.is_offset_needed)]
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntry.account_id)
.filter(JournalEntry.is_debit)
@ -147,7 +235,8 @@ class TransactionForm(FlaskForm):
:return: The selectable credit accounts.
"""
accounts: list[AccountOption] \
= [AccountOption(x) for x in Account.credit()]
= [AccountOption(x) for x in Account.credit()
if not (x.code[0] == "1" and x.is_offset_needed)]
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntry.account_id)
.filter(sa.not_(JournalEntry.is_debit))
@ -173,6 +262,46 @@ class TransactionForm(FlaskForm):
"""
return SummaryEditor()
@property
def original_entry_options(self) -> list[JournalEntry]:
"""Returns the selectable original entries.
:return: The selectable original entries.
"""
if self.__original_entry_options is None:
self.__original_entry_options = get_selectable_original_entries(
{x.eid.data for x in self.entries if x.eid.data is not None},
self._is_payable_needed, self._is_receivable_needed)
return self.__original_entry_options
@property
def min_date(self) -> dt.date | None:
"""Returns the minimal available date.
:return: The minimal available date.
"""
original_entry_id: set[int] \
= {x.original_entry_id.data for x in self.entries
if x.original_entry_id.data is not None}
if len(original_entry_id) == 0:
return None
select: sa.Select = sa.select(sa.func.max(Transaction.date))\
.join(JournalEntry).filter(JournalEntry.id.in_(original_entry_id))
return db.session.scalar(select)
@property
def max_date(self) -> dt.date | None:
"""Returns the maximum available date.
:return: The maximum available date.
"""
entry_id: set[int] = {x.eid.data for x in self.entries
if x.eid.data is not None}
select: sa.Select = sa.select(sa.func.min(Transaction.date))\
.join(JournalEntry)\
.filter(JournalEntry.original_entry_id.in_(entry_id))
return db.session.scalar(select)
T = t.TypeVar("T", bound=TransactionForm)
"""A transaction form variant."""
@ -314,16 +443,23 @@ class JournalEntryCollector(t.Generic[T], ABC):
class IncomeTransactionForm(TransactionForm):
"""The form to create or edit a cash income transaction."""
date = DateField(validators=[DATE_REQUIRED])
date = DateField(
validators=[DATE_REQUIRED,
NotBeforeOriginalEntries(),
NotAfterOffsetEntries()])
"""The date."""
currencies = FieldList(FormField(IncomeCurrencyForm), name="currency",
validators=[NeedSomeCurrencies()])
"""The journal entries categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text])
"""The note."""
whole_form = BooleanField(
validators=[CannotDeleteOriginalEntriesWithOffset()])
"""The pseudo field for the whole form validators."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._is_receivable_needed = True
class Collector(JournalEntryCollector[IncomeTransactionForm]):
"""The journal entry collector for the cash income transactions."""
@ -352,16 +488,23 @@ class IncomeTransactionForm(TransactionForm):
class ExpenseTransactionForm(TransactionForm):
"""The form to create or edit a cash expense transaction."""
date = DateField(validators=[DATE_REQUIRED])
date = DateField(
validators=[DATE_REQUIRED,
NotBeforeOriginalEntries(),
NotAfterOffsetEntries()])
"""The date."""
currencies = FieldList(FormField(ExpenseCurrencyForm), name="currency",
validators=[NeedSomeCurrencies()])
"""The journal entries categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text])
"""The note."""
whole_form = BooleanField(
validators=[CannotDeleteOriginalEntriesWithOffset()])
"""The pseudo field for the whole form validators."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._is_payable_needed = True
class Collector(JournalEntryCollector[ExpenseTransactionForm]):
"""The journal entry collector for the cash expense
@ -391,16 +534,24 @@ class ExpenseTransactionForm(TransactionForm):
class TransferTransactionForm(TransactionForm):
"""The form to create or edit a transfer transaction."""
date = DateField(validators=[DATE_REQUIRED])
date = DateField(
validators=[DATE_REQUIRED,
NotBeforeOriginalEntries(),
NotAfterOffsetEntries()])
"""The date."""
currencies = FieldList(FormField(TransferCurrencyForm), name="currency",
validators=[NeedSomeCurrencies()])
"""The journal entries categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text])
"""The note."""
whole_form = BooleanField(
validators=[CannotDeleteOriginalEntriesWithOffset()])
"""The pseudo field for the whole form validators."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._is_payable_needed = True
self._is_receivable_needed = True
class Collector(JournalEntryCollector[TransferTransactionForm]):
"""The journal entry collector for the transfer transactions."""

View File

@ -38,6 +38,10 @@ class AccountOption:
"""The string representation of the account option."""
self.is_in_use: bool = False
"""True if this account is in use, or False otherwise."""
self.is_offset_needed: bool = account.is_offset_needed
"""True if this account needs offset, or False otherwise."""
self.is_offset_chooser_needed: bool = False
"""True if this account needs an offset chooser, or False otherwise."""
def __str__(self) -> str:
"""Returns the string representation of the account option.

View File

@ -0,0 +1,39 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/15
# 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 SQLAlchemy alias for the offset entries.
"""
import typing as t
import sqlalchemy as sa
from accounting.models import JournalEntry
def offset_alias() -> sa.Alias:
"""Returns the SQLAlchemy alias for the offset entries.
:return: The SQLAlchemy alias for the offset entries.
"""
def as_from(model_cls: t.Any) -> sa.FromClause:
return model_cls
def as_alias(alias: t.Any) -> sa.Alias:
return alias
return as_alias(sa.alias(as_from(JournalEntry), name="offset"))

View File

@ -0,0 +1,82 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The selectable original entries.
"""
from decimal import Decimal
import sqlalchemy as sa
from flask_babel import LazyString
from sqlalchemy.orm import selectinload
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Account, Transaction, JournalEntry
from accounting.transaction.forms.journal_entry import JournalEntryForm
from accounting.utils.cast import be
from .offset_alias import offset_alias
def get_selectable_original_entries(
entry_id_on_form: set[int], is_payable: bool, is_receivable: bool) \
-> list[JournalEntry]:
"""Queries and returns the selectable original entries, with their net
balances. The offset amounts of the form is excluded.
:param entry_id_on_form: The ID of the journal entries on the form.
:param is_payable: True to check the payable original entries, or False
otherwise.
:param is_receivable: True to check the receivable original entries, or
False otherwise.
:return: The selectable original entries, with their net balances.
"""
assert is_payable or is_receivable
offset: sa.Alias = offset_alias()
net_balance: sa.Label = (JournalEntry.amount + sa.func.sum(sa.case(
(offset.c.id.in_(entry_id_on_form), 0),
(be(offset.c.is_debit == JournalEntry.is_debit), offset.c.amount),
else_=-offset.c.amount))).label("net_balance")
conditions: list[sa.BinaryExpression] = [Account.is_offset_needed]
sub_conditions: list[sa.BinaryExpression] = []
if is_payable:
sub_conditions.append(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntry.is_debit)))
if is_receivable:
sub_conditions.append(sa.and_(Account.base_code.startswith("1"),
JournalEntry.is_debit))
conditions.append(sa.or_(*sub_conditions))
select_net_balances: sa.Select = sa.select(JournalEntry.id, net_balance)\
.join(Account)\
.join(offset, be(JournalEntry.id == offset.c.original_entry_id),
isouter=True)\
.filter(*conditions)\
.group_by(JournalEntry.id)\
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
net_balances: dict[int, Decimal] \
= {x.id: x.net_balance
for x in db.session.execute(select_net_balances).all()}
entries: list[JournalEntry] = JournalEntry.query\
.filter(JournalEntry.id.in_({x for x in net_balances}))\
.join(Transaction)\
.order_by(Transaction.date, JournalEntry.is_debit, JournalEntry.no)\
.options(selectinload(JournalEntry.currency),
selectinload(JournalEntry.account),
selectinload(JournalEntry.transaction)).all()
for entry in entries:
entry.net_balance = entry.amount if net_balances[entry.id] is None \
else net_balances[entry.id]
return entries

View File

@ -218,7 +218,8 @@ class SummaryEditor:
JournalEntry.account_id,
sa.func.count().label("freq"))\
.filter(JournalEntry.summary.is_not(None),
JournalEntry.summary.like("_%—_%"))\
JournalEntry.summary.like("_%—_%"),
JournalEntry.original_entry_id.is_(None))\
.group_by(entry_type, tag_type, tag, JournalEntry.account_id)
result: list[sa.Row] = db.session.execute(select).all()
accounts: dict[int, Account] \

View File

@ -117,6 +117,7 @@ def show_transaction_edit_form(txn: Transaction) -> str:
if "form" in session:
form = txn_op.form(ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.obj = txn
form.validate()
else:
form = txn_op.form(obj=txn)
@ -134,6 +135,7 @@ def update_transaction(txn: Transaction) -> redirect:
"""
txn_op: TransactionOperator = get_txn_op(txn, is_check_as=True)
form: txn_op.form = txn_op.form(request.form)
form.obj = txn
if not form.validate():
flash_form_errors(form)
session["form"] = urlencode(list(request.form.items()))

656
tests/test_offset.py Normal file
View File

@ -0,0 +1,656 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/11
# 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 offset.
"""
import unittest
import httpx
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import db
from testlib import create_test_app, get_client
from testlib_offset import TestData, JournalEntryData, TransactionData, \
CurrencyData
from testlib_txn import Accounts, match_txn_detail
PREFIX: str = "/accounting/transactions"
"""The URL prefix for the transaction management."""
class OffsetTestCase(unittest.TestCase):
"""The offset test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_test_app()
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")
self.data: TestData = TestData(self.app, self.client, self.csrf_token)
def test_add_receivable_offset(self) -> None:
"""Tests to add the receivable offset.
:return: None.
"""
from accounting.models import Account, Transaction
create_uri: str = f"{PREFIX}/create/income?next=%2F_next"
store_uri: str = f"{PREFIX}/store/income"
form: dict[str, str]
response: httpx.Response
txn_data: TransactionData = TransactionData(
self.data.e_r_or3d.txn.days, [CurrencyData(
"USD",
[],
[JournalEntryData(Accounts.RECEIVABLE,
self.data.e_r_or1d.summary, "300",
original_entry=self.data.e_r_or1d),
JournalEntryData(Accounts.RECEIVABLE,
self.data.e_r_or1d.summary, "100",
original_entry=self.data.e_r_or1d),
JournalEntryData(Accounts.RECEIVABLE,
self.data.e_r_or3d.summary, "100",
original_entry=self.data.e_r_or3d)])])
# Non-existing original entry ID
form = txn_data.new_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = "9999"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# The same side
form = txn_data.new_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = self.data.e_p_or1c.id
form["currency-1-credit-1-account_code"] = self.data.e_p_or1c.account
form["currency-1-credit-1-amount"] = "100"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# The original entry does not need offset
with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE)
account.is_offset_needed = False
db.session.commit()
response = self.client.post(store_uri,
data=txn_data.new_form(self.csrf_token))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE)
account.is_offset_needed = True
db.session.commit()
# The original entry is also an offset
form = txn_data.new_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = self.data.e_p_of1d.id
form["currency-1-credit-1-account_code"] = self.data.e_p_of1d.account
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not the same currency
form = txn_data.new_form(self.csrf_token)
form["currency-1-code"] = "EUR"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not the same account
form = txn_data.new_form(self.csrf_token)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - partially offset
form = txn_data.new_form(self.csrf_token)
form["currency-1-credit-1-amount"] = "300.01"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - unmatched
form = txn_data.new_form(self.csrf_token)
form["currency-1-credit-3-amount"] = "100.01"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not before the original entries
txn_data.days = self.data.e_r_or3d.txn.days + 1
form = txn_data.new_form(self.csrf_token)
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
txn_data.days = 0
# Success
form = txn_data.new_form(self.csrf_token)
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
txn_id: int = match_txn_detail(response.headers["Location"])
with self.app.app_context():
txn = db.session.get(Transaction, txn_id)
for offset in txn.currencies[0].credit:
self.assertIsNotNone(offset.original_entry_id)
def test_edit_receivable_offset(self) -> None:
"""Tests to edit the receivable offset.
:return: None.
"""
from accounting.models import Account
txn_data: TransactionData = self.data.t_r_of2
edit_uri: str = f"{PREFIX}/{txn_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_data.id}/update"
form: dict[str, str]
response: httpx.Response
# Non-existing original entry ID
form = txn_data.update_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = "9999"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# The same side
form = txn_data.update_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = self.data.e_p_or1c.id
form["currency-1-credit-1-account_code"] = self.data.e_p_or1c.account
form["currency-1-debit-1-amount"] = "100"
form["currency-1-credit-1-amount"] = "100"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# The original entry does not need offset
with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE)
account.is_offset_needed = False
db.session.commit()
response = self.client.post(update_uri,
data=txn_data.update_form(self.csrf_token))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE)
account.is_offset_needed = True
db.session.commit()
# The original entry is also an offset
form = txn_data.update_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = self.data.e_p_of1d.id
form["currency-1-credit-1-account_code"] = self.data.e_p_of1d.account
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same currency
form = txn_data.update_form(self.csrf_token)
form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account
form = txn_data.update_form(self.csrf_token)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - partially offset
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-1-amount"] = "600.01"
form["currency-1-credit-1-amount"] = "600.01"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - unmatched
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-3-amount"] = "600.01"
form["currency-1-credit-3-amount"] = "600.01"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not before the original entries
txn_data.days = self.data.e_r_or3d.txn.days + 1
form = txn_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
txn_data.days = 0
# Success
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-1-amount"] = "600"
form["currency-1-credit-1-amount"] = "600"
form["currency-1-debit-3-amount"] = "600"
form["currency-1-credit-3-amount"] = "600"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{txn_data.id}?next=%2F_next")
def test_edit_receivable_original_entry(self) -> None:
"""Tests to edit the receivable original entry.
:return: None.
"""
from accounting.models import Transaction
txn_data: TransactionData = self.data.t_r_or1
edit_uri: str = f"{PREFIX}/{txn_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_data.id}/update"
form: dict[str, str]
response: httpx.Response
txn_data.days = self.data.t_r_of1.days
txn_data.currencies[0].debit[0].amount = "800"
txn_data.currencies[0].credit[0].amount = "800"
txn_data.currencies[0].debit[1].amount = "3.4"
txn_data.currencies[0].credit[1].amount = "3.4"
# Not the same currency
form = txn_data.update_form(self.csrf_token)
form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - partially offset
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-1-amount"] = "799.99"
form["currency-1-credit-1-amount"] = "799.99"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - fully offset
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-2-amount"] = "3.39"
form["currency-1-credit-2-amount"] = "3.39"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not after the offset entries
old_days: int = txn_data.days
txn_data.days = self.data.t_r_of1.days - 1
form = txn_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
txn_data.days = old_days
# Not deleting matched original entries
form = txn_data.update_form(self.csrf_token)
del form["currency-1-debit-1-eid"]
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Success
form = txn_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{txn_data.id}?next=%2F_next")
# The original entry is always before the offset entry, even when they
# happen in the same day.
with self.app.app_context():
txn_or: Transaction | None = db.session.get(
Transaction, txn_data.id)
self.assertIsNotNone(txn_or)
txn_of: Transaction | None = db.session.get(
Transaction, self.data.t_r_of1.id)
self.assertIsNotNone(txn_of)
self.assertEqual(txn_or.date, txn_of.date)
self.assertLess(txn_or.no, txn_of.no)
def test_add_payable_offset(self) -> None:
"""Tests to add the payable offset.
:return: None.
"""
from accounting.models import Account, Transaction
create_uri: str = f"{PREFIX}/create/expense?next=%2F_next"
store_uri: str = f"{PREFIX}/store/expense"
form: dict[str, str]
response: httpx.Response
txn_data: TransactionData = TransactionData(
self.data.e_p_or3c.txn.days, [CurrencyData(
"USD",
[JournalEntryData(Accounts.PAYABLE,
self.data.e_p_or1c.summary, "500",
original_entry=self.data.e_p_or1c),
JournalEntryData(Accounts.PAYABLE,
self.data.e_p_or1c.summary, "300",
original_entry=self.data.e_p_or1c),
JournalEntryData(Accounts.PAYABLE,
self.data.e_p_or3c.summary, "120",
original_entry=self.data.e_p_or3c)],
[])])
# Non-existing original entry ID
form = txn_data.new_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = "9999"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# The same side
form = txn_data.new_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = self.data.e_r_or1d.id
form["currency-1-debit-1-account_code"] = self.data.e_r_or1d.account
form["currency-1-debit-1-amount"] = "100"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# The original entry does not need offset
with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE)
account.is_offset_needed = False
db.session.commit()
response = self.client.post(store_uri,
data=txn_data.new_form(self.csrf_token))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE)
account.is_offset_needed = True
db.session.commit()
# The original entry is also an offset
form = txn_data.new_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = self.data.e_r_of1c.id
form["currency-1-debit-1-account_code"] = self.data.e_r_of1c.account
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not the same currency
form = txn_data.new_form(self.csrf_token)
form["currency-1-code"] = "EUR"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not the same account
form = txn_data.new_form(self.csrf_token)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - partially offset
form = txn_data.new_form(self.csrf_token)
form["currency-1-debit-1-amount"] = "500.01"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - unmatched
form = txn_data.new_form(self.csrf_token)
form["currency-1-debit-3-amount"] = "120.01"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not before the original entries
txn_data.days = self.data.e_p_or3c.txn.days + 1
form = txn_data.new_form(self.csrf_token)
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
txn_data.days = 0
# Success
form = txn_data.new_form(self.csrf_token)
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
txn_id: int = match_txn_detail(response.headers["Location"])
with self.app.app_context():
txn = db.session.get(Transaction, txn_id)
for offset in txn.currencies[0].debit:
self.assertIsNotNone(offset.original_entry_id)
def test_edit_payable_offset(self) -> None:
"""Tests to edit the payable offset.
:return: None.
"""
from accounting.models import Account, Transaction
txn_data: TransactionData = self.data.t_p_of2
edit_uri: str = f"{PREFIX}/{txn_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_data.id}/update"
form: dict[str, str]
response: httpx.Response
# Non-existing original entry ID
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = "9999"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# The same side
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = self.data.e_r_or1d.id
form["currency-1-debit-1-account_code"] = self.data.e_r_or1d.account
form["currency-1-debit-1-amount"] = "100"
form["currency-1-credit-1-amount"] = "100"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# The original entry does not need offset
with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE)
account.is_offset_needed = False
db.session.commit()
response = self.client.post(update_uri,
data=txn_data.update_form(self.csrf_token))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE)
account.is_offset_needed = True
db.session.commit()
# The original entry is also an offset
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = self.data.e_r_of1c.id
form["currency-1-debit-1-account_code"] = self.data.e_r_of1c.account
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same currency
form = txn_data.update_form(self.csrf_token)
form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - partially offset
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-1-amount"] = "1100.01"
form["currency-1-credit-1-amount"] = "1100.01"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - unmatched
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-3-amount"] = "900.01"
form["currency-1-credit-3-amount"] = "900.01"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not before the original entries
old_days: int = txn_data.days
txn_data.days = self.data.e_p_or3c.txn.days + 1
form = txn_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
txn_data.days = old_days
# Success
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-1-amount"] = "1100"
form["currency-1-credit-1-amount"] = "1100"
form["currency-1-debit-3-amount"] = "900"
form["currency-1-credit-3-amount"] = "900"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
txn_id: int = match_txn_detail(response.headers["Location"])
with self.app.app_context():
txn = db.session.get(Transaction, txn_id)
for offset in txn.currencies[0].debit:
self.assertIsNotNone(offset.original_entry_id)
def test_edit_payable_original_entry(self) -> None:
"""Tests to edit the payable original entry.
:return: None.
"""
from accounting.models import Transaction
txn_data: TransactionData = self.data.t_p_or1
edit_uri: str = f"{PREFIX}/{txn_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_data.id}/update"
form: dict[str, str]
response: httpx.Response
txn_data.days = self.data.t_p_of1.days
txn_data.currencies[0].debit[0].amount = "1200"
txn_data.currencies[0].credit[0].amount = "1200"
txn_data.currencies[0].debit[1].amount = "0.9"
txn_data.currencies[0].credit[1].amount = "0.9"
# Not the same currency
form = txn_data.update_form(self.csrf_token)
form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account
form = txn_data.update_form(self.csrf_token)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - partially offset
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-1-amount"] = "1199.99"
form["currency-1-credit-1-amount"] = "1199.99"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - fully offset
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-2-amount"] = "0.89"
form["currency-1-credit-2-amount"] = "0.89"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not after the offset entries
old_days: int = txn_data.days
txn_data.days = self.data.t_p_of1.days - 1
form = txn_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
txn_data.days = old_days
# Not deleting matched original entries
form = txn_data.update_form(self.csrf_token)
del form["currency-1-credit-1-eid"]
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Success
form = txn_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{txn_data.id}?next=%2F_next")
# The original entry is always before the offset entry, even when they
# happen in the same day
with self.app.app_context():
txn_or: Transaction | None = db.session.get(
Transaction, txn_data.id)
self.assertIsNotNone(txn_or)
txn_of: Transaction | None = db.session.get(
Transaction, self.data.t_p_of1.id)
self.assertIsNotNone(txn_of)
self.assertEqual(txn_or.date, txn_of.date)
self.assertLess(txn_or.no, txn_of.no)

View File

@ -229,6 +229,15 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# A receivable entry cannot start from the credit side
form = self.__get_add_form()
key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-credit-" in x][0]
form[key] = Accounts.RECEIVABLE
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Negative amount
form = self.__get_add_form()
set_negative_amount(form)
@ -380,6 +389,15 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# A receivable entry cannot start from the credit side
form = self.__get_add_form()
key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-credit-" in x][0]
form[key] = Accounts.RECEIVABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Negative amount
form: dict[str, str] = form_0.copy()
set_negative_amount(form)
@ -781,6 +799,15 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# A payable entry cannot start from the debit side
form = self.__get_add_form()
key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-debit-" in x][0]
form[key] = Accounts.PAYABLE
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Negative amount
form = self.__get_add_form()
set_negative_amount(form)
@ -935,6 +962,15 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# A payable entry cannot start from the debit side
form = self.__get_add_form()
key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-debit-" in x][0]
form[key] = Accounts.PAYABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Negative amount
form: dict[str, str] = form_0.copy()
set_negative_amount(form)
@ -1356,6 +1392,24 @@ class TransferTransactionTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# A receivable entry cannot start from the credit side
form = self.__get_add_form()
key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-credit-" in x][0]
form[key] = Accounts.RECEIVABLE
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# A payable entry cannot start from the debit side
form = self.__get_add_form()
key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-debit-" in x][0]
form[key] = Accounts.PAYABLE
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Negative amount
form = self.__get_add_form()
set_negative_amount(form)
@ -1537,6 +1591,24 @@ class TransferTransactionTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# A receivable entry cannot start from the credit side
form = self.__get_add_form()
key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-credit-" in x][0]
form[key] = Accounts.RECEIVABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# A payable entry cannot start from the debit side
form = self.__get_add_form()
key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-debit-" in x][0]
form[key] = Accounts.PAYABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Negative amount
form: dict[str, str] = form_0.copy()
set_negative_amount(form)

307
tests/testlib_offset.py Normal file
View File

@ -0,0 +1,307 @@
# 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 common test libraries for the offset test cases.
"""
from __future__ import annotations
from datetime import date, timedelta
import httpx
from flask import Flask
from test_site import db
from testlib_txn import Accounts, match_txn_detail, NEXT_URI
class JournalEntryData:
"""The journal entry data."""
def __init__(self, account: str, summary: str, amount: str,
original_entry: JournalEntryData | None = None):
"""Constructs the journal entry data.
:param account: The account code.
:param summary: The summary.
:param amount: The amount.
:param original_entry: The original entry.
"""
self.txn: TransactionData | None = None
self.id: int = -1
self.no: int = -1
self.original_entry: JournalEntryData | None = original_entry
self.account: str = account
self.summary: str = summary
self.amount: str = amount
def form(self, prefix: str, entry_type: str, index: int, is_update: bool) \
-> dict[str, str]:
"""Returns the journal entry as form data.
:param prefix: The prefix of the form fields.
:param entry_type: The entry type, either "debit" or "credit".
:param index: The entry index.
:param is_update: True for an update operation, or False otherwise
:return: The form data.
"""
prefix = f"{prefix}-{entry_type}-{index}"
form: dict[str, str] = {f"{prefix}-account_code": self.account,
f"{prefix}-summary": self.summary,
f"{prefix}-amount": self.amount}
if is_update and self.id != -1:
form[f"{prefix}-eid"] = str(self.id)
form[f"{prefix}-no"] = str(index) if self.no == -1 else str(self.no)
if self.original_entry is not None:
assert self.original_entry.id != -1
form[f"{prefix}-original_entry_id"] = str(self.original_entry.id)
return form
class CurrencyData:
"""The transaction currency data."""
def __init__(self, currency: str, debit: list[JournalEntryData],
credit: list[JournalEntryData]):
"""Constructs the transaction currency data.
:param currency: The currency code.
:param debit: The debit journal entries.
:param credit: The credit journal entries.
"""
self.code: str = currency
self.debit: list[JournalEntryData] = debit
self.credit: list[JournalEntryData] = credit
def form(self, index: int, is_update: bool) -> dict[str, str]:
"""Returns the currency as form data.
:param index: The currency index.
:param is_update: True for an update operation, or False otherwise
:return: The form data.
"""
prefix: str = f"currency-{index}"
form: dict[str, str] = {f"{prefix}-code": self.code}
for i in range(len(self.debit)):
form.update(self.debit[i].form(prefix, "debit", i + 1, is_update))
for i in range(len(self.credit)):
form.update(self.credit[i].form(prefix, "credit", i + 1,
is_update))
return form
class TransactionData:
"""The transaction data."""
def __init__(self, days: int, currencies: list[CurrencyData]):
"""Constructs a transaction.
:param days: The number of days before today.
:param currencies: The transaction currency data.
"""
self.id: int = -1
self.days: int = days
self.currencies: list[CurrencyData] = currencies
self.note: str | None = None
for currency in self.currencies:
for entry in currency.debit:
entry.txn = self
for entry in currency.credit:
entry.txn = self
def new_form(self, csrf_token: str) -> dict[str, str]:
"""Returns the transaction as a form.
:param csrf_token: The CSRF token.
:return: The transaction as a form.
"""
return self.__form(csrf_token, is_update=False)
def update_form(self, csrf_token: str) -> dict[str, str]:
"""Returns the transaction as a form.
:param csrf_token: The CSRF token.
:return: The transaction as a form.
"""
return self.__form(csrf_token, is_update=True)
def __form(self, csrf_token: str, is_update: bool = False) \
-> dict[str, str]:
"""Returns the transaction as a form.
:param csrf_token: The CSRF token.
:param is_update: True for an update operation, or False otherwise
:return: The transaction as a form.
"""
txn_date: date = date.today() - timedelta(days=self.days)
form: dict[str, str] = {"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date.isoformat()}
for i in range(len(self.currencies)):
form.update(self.currencies[i].form(i + 1, is_update))
if self.note is not None:
form["note"] = self.note
return form
class TestData:
"""The test data."""
def __init__(self, app: Flask, client: httpx.Client, csrf_token: str):
"""Constructs the test data.
:param app: The Flask application.
:param client: The client.
:param csrf_token: The CSRF token.
"""
self.app: Flask = app
self.client: httpx.Client = client
self.csrf_token: str = csrf_token
def couple(summary: str, amount: str, debit: str, credit: str) \
-> tuple[JournalEntryData, JournalEntryData]:
"""Returns a couple of debit-credit journal entries.
:param summary: The summary.
:param amount: The amount.
:param debit: The debit account code.
:param credit: The credit account code.
:return: The debit journal entry and credit journal entry.
"""
return JournalEntryData(debit, summary, amount),\
JournalEntryData(credit, summary, amount)
# Receivable original entries
self.e_r_or1d, self.e_r_or1c = couple(
"Accountant", "1200", Accounts.RECEIVABLE, Accounts.SERVICE)
self.e_r_or2d, self.e_r_or2c = couple(
"Toy", "600", Accounts.RECEIVABLE, Accounts.SALES)
self.e_r_or3d, self.e_r_or3c = couple(
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.e_r_or4d, self.e_r_or4c = couple(
"Interest", "3.4", Accounts.RECEIVABLE, Accounts.INTEREST)
# Payable original entries
self.e_p_or1d, self.e_p_or1c = couple(
"Airplane ticket", "2000", Accounts.TRAVEL, Accounts.PAYABLE)
self.e_p_or2d, self.e_p_or2c = couple(
"Phone", "900", Accounts.OFFICE, Accounts.PAYABLE)
self.e_p_or3d, self.e_p_or3c = couple(
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.e_p_or4d, self.e_p_or4c = couple(
"Envelop", "0.9", Accounts.OFFICE, Accounts.PAYABLE)
# Original transactions
self.t_r_or1: TransactionData = TransactionData(
50, [CurrencyData("USD", [self.e_r_or1d, self.e_r_or4d],
[self.e_r_or1c, self.e_r_or4c])])
self.t_r_or2: TransactionData = TransactionData(
30, [CurrencyData("USD", [self.e_r_or2d, self.e_r_or3d],
[self.e_r_or2c, self.e_r_or3c])])
self.t_p_or1: TransactionData = TransactionData(
40, [CurrencyData("USD", [self.e_p_or1d, self.e_p_or4d],
[self.e_p_or1c, self.e_p_or4c])])
self.t_p_or2: TransactionData = TransactionData(
20, [CurrencyData("USD", [self.e_p_or2d, self.e_p_or3d],
[self.e_p_or2c, self.e_p_or3c])])
self.__add_txn(self.t_r_or1)
self.__add_txn(self.t_r_or2)
self.__add_txn(self.t_p_or1)
self.__add_txn(self.t_p_or2)
# Receivable offset entries
self.e_r_of1d, self.e_r_of1c = couple(
"Accountant", "500", Accounts.CASH, Accounts.RECEIVABLE)
self.e_r_of1c.original_entry = self.e_r_or1d
self.e_r_of2d, self.e_r_of2c = couple(
"Accountant", "200", Accounts.CASH, Accounts.RECEIVABLE)
self.e_r_of2c.original_entry = self.e_r_or1d
self.e_r_of3d, self.e_r_of3c = couple(
"Accountant", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.e_r_of3c.original_entry = self.e_r_or1d
self.e_r_of4d, self.e_r_of4c = couple(
"Toy", "240", Accounts.CASH, Accounts.RECEIVABLE)
self.e_r_of4c.original_entry = self.e_r_or2d
self.e_r_of5d, self.e_r_of5c = couple(
"Interest", "3.4", Accounts.CASH, Accounts.RECEIVABLE)
self.e_r_of5c.original_entry = self.e_r_or4d
# Payable offset entries
self.e_p_of1d, self.e_p_of1c = couple(
"Airplane ticket", "800", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of1d.original_entry = self.e_p_or1c
self.e_p_of2d, self.e_p_of2c = couple(
"Airplane ticket", "300", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of2d.original_entry = self.e_p_or1c
self.e_p_of3d, self.e_p_of3c = couple(
"Airplane ticket", "100", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of3d.original_entry = self.e_p_or1c
self.e_p_of4d, self.e_p_of4c = couple(
"Phone", "400", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of4d.original_entry = self.e_p_or2c
self.e_p_of5d, self.e_p_of5c = couple(
"Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of5d.original_entry = self.e_p_or4c
# Offset transactions
self.t_r_of1: TransactionData = TransactionData(
25, [CurrencyData("USD", [self.e_r_of1d], [self.e_r_of1c])])
self.t_r_of2: TransactionData = TransactionData(
20, [CurrencyData("USD",
[self.e_r_of2d, self.e_r_of3d, self.e_r_of4d],
[self.e_r_of2c, self.e_r_of3c, self.e_r_of4c])])
self.t_r_of3: TransactionData = TransactionData(
15, [CurrencyData("USD", [self.e_r_of5d], [self.e_r_of5c])])
self.t_p_of1: TransactionData = TransactionData(
15, [CurrencyData("USD", [self.e_p_of1d], [self.e_p_of1c])])
self.t_p_of2: TransactionData = TransactionData(
10, [CurrencyData("USD",
[self.e_p_of2d, self.e_p_of3d, self.e_p_of4d],
[self.e_p_of2c, self.e_p_of3c, self.e_p_of4c])])
self.t_p_of3: TransactionData = TransactionData(
5, [CurrencyData("USD", [self.e_p_of5d], [self.e_p_of5c])])
self.__add_txn(self.t_r_of1)
self.__add_txn(self.t_r_of2)
self.__add_txn(self.t_r_of3)
self.__add_txn(self.t_p_of1)
self.__add_txn(self.t_p_of2)
self.__add_txn(self.t_p_of3)
def __add_txn(self, txn_data: TransactionData) -> None:
"""Adds a transaction.
:param txn_data: The transaction data.
:return: None.
"""
from accounting.models import Transaction
store_uri: str = "/accounting/transactions/store/transfer"
response: httpx.Response = self.client.post(
store_uri, data=txn_data.new_form(self.csrf_token))
assert response.status_code == 302
txn_id: int = match_txn_detail(response.headers["Location"])
txn_data.id = txn_id
with self.app.app_context():
txn: Transaction | None = db.session.get(Transaction, txn_id)
assert txn is not None
for i in range(len(txn.currencies)):
for j in range(len(txn.currencies[i].debit)):
txn_data.currencies[i].debit[j].id \
= txn.currencies[i].debit[j].id
for j in range(len(txn.currencies[i].credit)):
txn_data.currencies[i].credit[j].id \
= txn.currencies[i].credit[j].id

View File

@ -40,7 +40,10 @@ class Accounts:
CASH: str = "1111-001"
PETTY_CASH: str = "1112-001"
BANK: str = "1113-001"
NOTES_RECEIVABLE: str = "1131-001"
RECEIVABLE: str = "1141-001"
PREPAID: str = "1258-001"
NOTES_PAYABLE: str = "2131-001"
PAYABLE: str = "2141-001"
SALES: str = "4111-001"
SERVICE: str = "4611-001"
@ -48,7 +51,7 @@ class Accounts:
OFFICE: str = "6153-001"
TRAVEL: str = "6154-001"
MEAL: str = "6172-001"
INTEREST: str = "4111-001"
INTEREST: str = "7111-001"
DONATION: str = "7481-001"
RENT: str = "7482-001"