Added to track the net balance and offset of the original entries.
This commit is contained in:
parent
40e329d37f
commit
d88b3ac770
@ -21,6 +21,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import typing as t
|
import typing as t
|
||||||
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@ -568,6 +569,21 @@ class Transaction(db.Model):
|
|||||||
return False
|
return False
|
||||||
return True
|
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:
|
def delete(self) -> None:
|
||||||
"""Deletes the transaction.
|
"""Deletes the transaction.
|
||||||
|
|
||||||
@ -624,6 +640,21 @@ class JournalEntry(db.Model):
|
|||||||
amount = db.Column(db.Numeric(14, 2), nullable=False)
|
amount = db.Column(db.Numeric(14, 2), nullable=False)
|
||||||
"""The amount."""
|
"""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
|
@property
|
||||||
def eid(self) -> int | None:
|
def eid(self) -> int | None:
|
||||||
"""Returns the journal entry ID. This is the alternative name of the
|
"""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
|
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
|
@property
|
||||||
def credit(self) -> Decimal | None:
|
def credit(self) -> Decimal | None:
|
||||||
"""Returns the credit amount.
|
"""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: The credit amount, or None if this is not a credit entry.
|
||||||
"""
|
"""
|
||||||
return None if self.is_debit else self.amount
|
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)])
|
||||||
|
@ -31,6 +31,9 @@
|
|||||||
color: #141619;
|
color: #141619;
|
||||||
background-color: #D3D3D4;
|
background-color: #D3D3D4;
|
||||||
}
|
}
|
||||||
|
.form-control.accounting-disabled {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
/** The toolbar */
|
/** The toolbar */
|
||||||
.accounting-toolbar {
|
.accounting-toolbar {
|
||||||
@ -113,6 +116,33 @@
|
|||||||
border-bottom: thick double slategray;
|
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 */
|
/** The option selector */
|
||||||
.accounting-selector-list {
|
.accounting-selector-list {
|
||||||
height: 20rem;
|
height: 20rem;
|
||||||
@ -150,6 +180,9 @@
|
|||||||
font-weight: bolder;
|
font-weight: bolder;
|
||||||
border-top: thick double slategray;
|
border-top: thick double slategray;
|
||||||
}
|
}
|
||||||
|
.accounting-entry-editor-original-entry-content {
|
||||||
|
width: calc(100% - 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
/* The report table */
|
/* The report table */
|
||||||
.accounting-report-table-header, .accounting-report-table-footer {
|
.accounting-report-table-header, .accounting-report-table-footer {
|
||||||
|
@ -111,7 +111,7 @@ class AccountSelector {
|
|||||||
};
|
};
|
||||||
for (const option of this.#options) {
|
for (const option of this.#options) {
|
||||||
option.onclick = () => {
|
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", () => {
|
this.#query.addEventListener("input", () => {
|
||||||
|
@ -57,6 +57,36 @@ class JournalEntryEditor {
|
|||||||
*/
|
*/
|
||||||
#prefix = "accounting-entry-editor"
|
#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
|
* The control of the summary
|
||||||
* @type {HTMLDivElement}
|
* @type {HTMLDivElement}
|
||||||
@ -109,7 +139,7 @@ class JournalEntryEditor {
|
|||||||
* The journal entry to edit
|
* The journal entry to edit
|
||||||
* @type {JournalEntrySubForm|null}
|
* @type {JournalEntrySubForm|null}
|
||||||
*/
|
*/
|
||||||
#entry;
|
entry;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The debit or credit entry side sub-form
|
* The debit or credit entry side sub-form
|
||||||
@ -124,6 +154,11 @@ class JournalEntryEditor {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.#element = document.getElementById(this.#prefix);
|
this.#element = document.getElementById(this.#prefix);
|
||||||
this.#modal = document.getElementById(this.#prefix + "-modal");
|
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.#summaryControl = document.getElementById(this.#prefix + "-summary-control");
|
||||||
this.#summary = document.getElementById(this.#prefix + "-summary");
|
this.#summary = document.getElementById(this.#prefix + "-summary");
|
||||||
this.#summaryError = document.getElementById(this.#prefix + "-summary-error");
|
this.#summaryError = document.getElementById(this.#prefix + "-summary-error");
|
||||||
@ -131,25 +166,90 @@ class JournalEntryEditor {
|
|||||||
this.#account = document.getElementById(this.#prefix + "-account");
|
this.#account = document.getElementById(this.#prefix + "-account");
|
||||||
this.#accountError = document.getElementById(this.#prefix + "-account-error")
|
this.#accountError = document.getElementById(this.#prefix + "-account-error")
|
||||||
this.#amount = document.getElementById(this.#prefix + "-amount");
|
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 = () => {
|
this.#summaryControl.onclick = () => {
|
||||||
SummaryEditor.start(this, this.#summary.dataset.value);
|
SummaryEditor.start(this, this.#summary.dataset.value);
|
||||||
};
|
};
|
||||||
this.#accountControl.onclick = () => {
|
this.#accountControl.onclick = () => {
|
||||||
AccountSelector.start(this, this.entryType);
|
AccountSelector.start(this, this.entryType);
|
||||||
}
|
}
|
||||||
|
this.#amount.onchange = () => {
|
||||||
|
this.#validateAmount();
|
||||||
|
}
|
||||||
this.#element.onsubmit = () => {
|
this.#element.onsubmit = () => {
|
||||||
if (this.#validate()) {
|
if (this.#validate()) {
|
||||||
if (this.#entry === null) {
|
if (this.entry === null) {
|
||||||
this.#entry = this.#side.addJournalEntry();
|
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();
|
bootstrap.Modal.getInstance(this.#modal).hide();
|
||||||
}
|
}
|
||||||
return false;
|
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.
|
* Returns the transaction form.
|
||||||
*
|
*
|
||||||
@ -172,6 +272,7 @@ class JournalEntryEditor {
|
|||||||
}
|
}
|
||||||
this.#summary.dataset.value = summary;
|
this.#summary.dataset.value = summary;
|
||||||
this.#summary.innerText = summary;
|
this.#summary.innerText = summary;
|
||||||
|
this.#validateSummary();
|
||||||
bootstrap.Modal.getOrCreateInstance(this.#modal).show();
|
bootstrap.Modal.getOrCreateInstance(this.#modal).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,8 +282,14 @@ class JournalEntryEditor {
|
|||||||
* @param summary {string} the summary
|
* @param summary {string} the summary
|
||||||
* @param accountCode {string} the account code
|
* @param accountCode {string} the account code
|
||||||
* @param accountText {string} the account text
|
* @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.#accountControl.classList.add("accounting-not-empty");
|
||||||
this.#account.dataset.code = accountCode;
|
this.#account.dataset.code = accountCode;
|
||||||
this.#account.dataset.text = accountText;
|
this.#account.dataset.text = accountText;
|
||||||
@ -205,6 +312,7 @@ class JournalEntryEditor {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
clearAccount() {
|
clearAccount() {
|
||||||
|
delete this.#element.dataset.isOriginalEntry;
|
||||||
this.#accountControl.classList.remove("accounting-not-empty");
|
this.#accountControl.classList.remove("accounting-not-empty");
|
||||||
this.#account.dataset.code = "";
|
this.#account.dataset.code = "";
|
||||||
this.#account.dataset.text = "";
|
this.#account.dataset.text = "";
|
||||||
@ -217,8 +325,14 @@ class JournalEntryEditor {
|
|||||||
*
|
*
|
||||||
* @param code {string} the account code
|
* @param code {string} the account code
|
||||||
* @param text {string} the account text
|
* @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.#accountControl.classList.add("accounting-not-empty");
|
||||||
this.#account.dataset.code = code;
|
this.#account.dataset.code = code;
|
||||||
this.#account.dataset.text = text;
|
this.#account.dataset.text = text;
|
||||||
@ -233,12 +347,25 @@ class JournalEntryEditor {
|
|||||||
*/
|
*/
|
||||||
#validate() {
|
#validate() {
|
||||||
let isValid = true;
|
let isValid = true;
|
||||||
|
isValid = this.#validateOriginalEntry() && isValid;
|
||||||
isValid = this.#validateSummary() && isValid;
|
isValid = this.#validateSummary() && isValid;
|
||||||
isValid = this.#validateAccount() && isValid;
|
isValid = this.#validateAccount() && isValid;
|
||||||
isValid = this.#validateAmount() && isValid
|
isValid = this.#validateAmount() && isValid
|
||||||
return 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.
|
* Validates the summary.
|
||||||
*
|
*
|
||||||
@ -281,8 +408,29 @@ class JournalEntryEditor {
|
|||||||
this.#amountError.innerText = A_("Please fill in the amount.");
|
this.#amountError.innerText = A_("Please fill in the amount.");
|
||||||
return false;
|
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.classList.remove("is-invalid");
|
||||||
this.#amount.innerText = "";
|
this.#amountError.innerText = "";
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -292,24 +440,33 @@ class JournalEntryEditor {
|
|||||||
* @param side {DebitCreditSideSubForm} the debit or credit side sub-form
|
* @param side {DebitCreditSideSubForm} the debit or credit side sub-form
|
||||||
*/
|
*/
|
||||||
#onAddNew(side) {
|
#onAddNew(side) {
|
||||||
this.#entry = null;
|
this.entry = null;
|
||||||
this.#side = side;
|
this.#side = side;
|
||||||
this.entryType = this.#side.entryType;
|
this.entryType = this.#side.entryType;
|
||||||
this.#element.dataset.entryType = 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("accounting-not-empty");
|
||||||
this.#summaryControl.classList.remove("is-invalid");
|
this.#summaryControl.classList.remove("is-invalid");
|
||||||
this.#summary.dataset.value = "";
|
this.#summary.dataset.value = "";
|
||||||
this.#summary.innerText = ""
|
this.#summary.innerText = ""
|
||||||
this.#summaryError.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("accounting-not-empty");
|
||||||
this.#accountControl.classList.remove("is-invalid");
|
this.#accountControl.classList.remove("is-invalid");
|
||||||
this.#account.innerText = "";
|
|
||||||
this.#account.dataset.code = "";
|
this.#account.dataset.code = "";
|
||||||
this.#account.dataset.text = "";
|
this.#account.dataset.text = "";
|
||||||
|
this.#account.innerText = "";
|
||||||
this.#accountError.innerText = "";
|
this.#accountError.innerText = "";
|
||||||
this.#amount.value = "";
|
this.#amount.value = "";
|
||||||
|
this.#amount.max = "";
|
||||||
|
this.#amount.min = "0";
|
||||||
this.#amount.classList.remove("is-invalid");
|
this.#amount.classList.remove("is-invalid");
|
||||||
this.#amountError.innerText = "";
|
this.#amountError.innerText = "";
|
||||||
}
|
}
|
||||||
@ -318,17 +475,37 @@ class JournalEntryEditor {
|
|||||||
* Edits a journal entry.
|
* Edits a journal entry.
|
||||||
*
|
*
|
||||||
* @param entry {JournalEntrySubForm} the journal entry sub-form
|
* @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 summary {string} the summary
|
||||||
* @param accountCode {string} the account code
|
* @param accountCode {string} the account code
|
||||||
* @param accountText {string} the account text
|
* @param accountText {string} the account text
|
||||||
* @param amount {string} the amount
|
* @param amount {string} the amount
|
||||||
|
* @param amountMin {string} the minimal amount
|
||||||
*/
|
*/
|
||||||
#onEdit(entry, summary, accountCode, accountText, amount) {
|
#onEdit(entry, originalEntryId, originalEntryDate, originalEntryText, summary, accountCode, accountText, amount, amountMin) {
|
||||||
this.#entry = entry;
|
this.entry = entry;
|
||||||
this.#side = entry.side;
|
this.#side = entry.side;
|
||||||
this.entryType = this.#side.entryType;
|
this.entryType = this.#side.entryType;
|
||||||
this.#element.dataset.entryType = entry.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 === "") {
|
if (summary === "") {
|
||||||
this.#summaryControl.classList.remove("accounting-not-empty");
|
this.#summaryControl.classList.remove("accounting-not-empty");
|
||||||
} else {
|
} else {
|
||||||
@ -336,16 +513,58 @@ class JournalEntryEditor {
|
|||||||
}
|
}
|
||||||
this.#summary.dataset.value = summary;
|
this.#summary.dataset.value = summary;
|
||||||
this.#summary.innerText = summary;
|
this.#summary.innerText = summary;
|
||||||
this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + entry.entryType + "-modal";
|
|
||||||
if (accountCode === "") {
|
if (accountCode === "") {
|
||||||
this.#accountControl.classList.remove("accounting-not-empty");
|
this.#accountControl.classList.remove("accounting-not-empty");
|
||||||
} else {
|
} else {
|
||||||
this.#accountControl.classList.add("accounting-not-empty");
|
this.#accountControl.classList.add("accounting-not-empty");
|
||||||
}
|
}
|
||||||
this.#account.innerText = accountText;
|
|
||||||
this.#account.dataset.code = accountCode;
|
this.#account.dataset.code = accountCode;
|
||||||
this.#account.dataset.text = accountText;
|
this.#account.dataset.text = accountText;
|
||||||
|
this.#account.innerText = accountText;
|
||||||
this.#amount.value = amount;
|
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.
|
* Edits a journal entry.
|
||||||
*
|
*
|
||||||
* @param entry {JournalEntrySubForm} the journal entry sub-form
|
* @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 summary {string} the summary
|
||||||
* @param accountCode {string} the account code
|
* @param accountCode {string} the account code
|
||||||
* @param accountText {string} the account text
|
* @param accountText {string} the account text
|
||||||
* @param amount {string} the amount
|
* @param amount {string} the amount
|
||||||
|
* @param amountMin {string} the minimal amount
|
||||||
*/
|
*/
|
||||||
static edit(entry, summary, accountCode, accountText, amount) {
|
static edit(entry, originalEntryId, originalEntryDate, originalEntryText, summary, accountCode, accountText, amount, amountMin) {
|
||||||
this.#editor.#onEdit(entry, summary, accountCode, accountText, amount);
|
this.#editor.#onEdit(entry, originalEntryId, originalEntryDate, originalEntryText, summary, accountCode, accountText, amount, amountMin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
447
src/accounting/static/js/original-entry-selector.js
Normal file
447
src/accounting/static/js/original-entry-selector.js
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -69,6 +69,12 @@ class SummaryEditor {
|
|||||||
*/
|
*/
|
||||||
summary;
|
summary;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The button to the original entry selector
|
||||||
|
* @type {HTMLButtonElement}
|
||||||
|
*/
|
||||||
|
#offsetButton;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The number input
|
* The number input
|
||||||
* @type {HTMLInputElement}
|
* @type {HTMLInputElement}
|
||||||
@ -116,6 +122,7 @@ class SummaryEditor {
|
|||||||
this.prefix = "accounting-summary-editor-" + form.dataset.entryType;
|
this.prefix = "accounting-summary-editor-" + form.dataset.entryType;
|
||||||
this.#modal = document.getElementById(this.prefix + "-modal");
|
this.#modal = document.getElementById(this.prefix + "-modal");
|
||||||
this.summary = document.getElementById(this.prefix + "-summary");
|
this.summary = document.getElementById(this.prefix + "-summary");
|
||||||
|
this.#offsetButton = document.getElementById(this.prefix + "-offset");
|
||||||
this.number = document.getElementById(this.prefix + "-annotation-number");
|
this.number = document.getElementById(this.prefix + "-annotation-number");
|
||||||
this.note = document.getElementById(this.prefix + "-annotation-note");
|
this.note = document.getElementById(this.prefix + "-annotation-note");
|
||||||
// noinspection JSValidateTypes
|
// noinspection JSValidateTypes
|
||||||
@ -128,6 +135,7 @@ class SummaryEditor {
|
|||||||
this.currentTab = this.tabPlanes.general;
|
this.currentTab = this.tabPlanes.general;
|
||||||
this.#initializeSuggestedAccounts();
|
this.#initializeSuggestedAccounts();
|
||||||
this.summary.onchange = () => this.#onSummaryChange();
|
this.summary.onchange = () => this.#onSummaryChange();
|
||||||
|
this.#offsetButton.onclick = () => OriginalEntrySelector.start(this.#entryEditor);
|
||||||
this.#form.onsubmit = () => {
|
this.#form.onsubmit = () => {
|
||||||
if (this.currentTab.validate()) {
|
if (this.currentTab.validate()) {
|
||||||
this.#submit();
|
this.#submit();
|
||||||
@ -210,7 +218,7 @@ class SummaryEditor {
|
|||||||
#submit() {
|
#submit() {
|
||||||
bootstrap.Modal.getOrCreateInstance(this.#modal).hide();
|
bootstrap.Modal.getOrCreateInstance(this.#modal).hide();
|
||||||
if (this.#selectedAccount !== null) {
|
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 {
|
} else {
|
||||||
this.#entryEditor.saveSummary(this.summary.value);
|
this.#entryEditor.saveSummary(this.summary.value);
|
||||||
}
|
}
|
||||||
|
@ -159,7 +159,19 @@ class TransactionForm {
|
|||||||
this.#currencies[0].deleteButton.classList.add("d-none");
|
this.#currencies[0].deleteButton.classList.add("d-none");
|
||||||
} else {
|
} else {
|
||||||
for (const currency of this.#currencies) {
|
for (const currency of this.#currencies) {
|
||||||
currency.deleteButton.classList.remove("d-none");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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.
|
* Returns the account codes used in the form.
|
||||||
*
|
*
|
||||||
@ -185,11 +211,35 @@ class TransactionForm {
|
|||||||
* @return {string[]} the account codes used in the form
|
* @return {string[]} the account codes used in the form
|
||||||
*/
|
*/
|
||||||
getAccountCodesUsed(entryType) {
|
getAccountCodesUsed(entryType) {
|
||||||
let inUse = [];
|
return this.getEntries(entryType).map((entry) => entry.getAccountCode())
|
||||||
for (const currency of this.#currencies) {
|
.filter((code) => code !== null);
|
||||||
inUse = inUse.concat(currency.getAccountCodesUsed(entryType));
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return inUse;
|
this.#date.min = lastOriginalEntryDate === null? "": lastOriginalEntryDate;
|
||||||
|
this.#validateDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -218,6 +268,11 @@ class TransactionForm {
|
|||||||
this.#dateError.innerText = A_("Please fill in the date.");
|
this.#dateError.innerText = A_("Please fill in the date.");
|
||||||
return false;
|
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.#date.classList.remove("is-invalid");
|
||||||
this.#dateError.innerText = "";
|
this.#dateError.innerText = "";
|
||||||
return true;
|
return true;
|
||||||
@ -330,6 +385,18 @@ class CurrencySubForm {
|
|||||||
*/
|
*/
|
||||||
no;
|
no;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The currency code
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The currency code selector
|
||||||
|
* @type {HTMLSelectElement}
|
||||||
|
*/
|
||||||
|
#codeSelect;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The button to delete the currency
|
* The button to delete the currency
|
||||||
* @type {HTMLButtonElement}
|
* @type {HTMLButtonElement}
|
||||||
@ -362,11 +429,16 @@ class CurrencySubForm {
|
|||||||
this.#control = document.getElementById(this.#prefix + "-control");
|
this.#control = document.getElementById(this.#prefix + "-control");
|
||||||
this.#error = document.getElementById(this.#prefix + "-error");
|
this.#error = document.getElementById(this.#prefix + "-error");
|
||||||
this.no = document.getElementById(this.#prefix + "-no");
|
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");
|
this.deleteButton = document.getElementById(this.#prefix + "-delete");
|
||||||
const debitElement = document.getElementById(this.#prefix + "-debit");
|
const debitElement = document.getElementById(this.#prefix + "-debit");
|
||||||
this.#debit = debitElement === null? null: new DebitCreditSideSubForm(this, debitElement, "debit");
|
this.#debit = debitElement === null? null: new DebitCreditSideSubForm(this, debitElement, "debit");
|
||||||
const creditElement = document.getElementById(this.#prefix + "-credit");
|
const creditElement = document.getElementById(this.#prefix + "-credit");
|
||||||
this.#credit = creditElement == null? null: new DebitCreditSideSubForm(this, creditElement, "credit");
|
this.#credit = creditElement == null? null: new DebitCreditSideSubForm(this, creditElement, "credit");
|
||||||
|
this.#codeSelect.onchange = () => {
|
||||||
|
this.#code.value = this.#codeSelect.value;
|
||||||
|
};
|
||||||
this.deleteButton.onclick = () => {
|
this.deleteButton.onclick = () => {
|
||||||
this.element.parentElement.removeChild(this.element);
|
this.element.parentElement.removeChild(this.element);
|
||||||
this.form.deleteCurrency(this);
|
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 currency code
|
||||||
* @return {string[]} the account codes used in the form
|
|
||||||
*/
|
*/
|
||||||
getAccountCodesUsed(entryType) {
|
getCurrencyCode() {
|
||||||
if (entryType === "debit") {
|
return this.#code.value;
|
||||||
return this.#debit.getAccountCodesUsed();
|
}
|
||||||
} else if (entryType === "credit") {
|
|
||||||
return this.#credit.getAccountCodesUsed();
|
/**
|
||||||
|
* 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 []
|
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
|
* The journal entry sub-forms
|
||||||
* @type {JournalEntrySubForm[]}
|
* @type {JournalEntrySubForm[]}
|
||||||
*/
|
*/
|
||||||
#entries;
|
entries;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The total
|
* The total
|
||||||
@ -506,7 +605,7 @@ class DebitCreditSideSubForm {
|
|||||||
this.#error = document.getElementById(this.#prefix + "-error");
|
this.#error = document.getElementById(this.#prefix + "-error");
|
||||||
this.#entryList = document.getElementById(this.#prefix + "-list");
|
this.#entryList = document.getElementById(this.#prefix + "-list");
|
||||||
// noinspection JSValidateTypes
|
// 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.#total = document.getElementById(this.#prefix + "-total");
|
||||||
this.#addEntryButton = document.getElementById(this.#prefix + "-add-entry");
|
this.#addEntryButton = document.getElementById(this.#prefix + "-add-entry");
|
||||||
this.#addEntryButton.onclick = () => {
|
this.#addEntryButton.onclick = () => {
|
||||||
@ -522,14 +621,14 @@ class DebitCreditSideSubForm {
|
|||||||
* @returns {JournalEntrySubForm} the newly-added journal entry sub-form
|
* @returns {JournalEntrySubForm} the newly-added journal entry sub-form
|
||||||
*/
|
*/
|
||||||
addJournalEntry() {
|
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
|
const html = this.currency.form.entryTemplate
|
||||||
.replaceAll("CURRENCY_INDEX", escapeHtml(String(this.#currencyIndex)))
|
.replaceAll("CURRENCY_INDEX", escapeHtml(String(this.#currencyIndex)))
|
||||||
.replaceAll("ENTRY_TYPE", escapeHtml(this.entryType))
|
.replaceAll("ENTRY_TYPE", escapeHtml(this.entryType))
|
||||||
.replaceAll("ENTRY_INDEX", escapeHtml(String(newIndex)));
|
.replaceAll("ENTRY_INDEX", escapeHtml(String(newIndex)));
|
||||||
this.#entryList.insertAdjacentHTML("beforeend", html);
|
this.#entryList.insertAdjacentHTML("beforeend", html);
|
||||||
const entry = new JournalEntrySubForm(this, document.getElementById(this.#prefix + "-" + String(newIndex)));
|
const entry = new JournalEntrySubForm(this, document.getElementById(this.#prefix + "-" + String(newIndex)));
|
||||||
this.#entries.push(entry);
|
this.entries.push(entry);
|
||||||
this.#resetDeleteJournalEntryButtons();
|
this.#resetDeleteJournalEntryButtons();
|
||||||
this.#initializeDragAndDropReordering();
|
this.#initializeDragAndDropReordering();
|
||||||
this.validate();
|
this.validate();
|
||||||
@ -542,9 +641,11 @@ class DebitCreditSideSubForm {
|
|||||||
* @param entry {JournalEntrySubForm}
|
* @param entry {JournalEntrySubForm}
|
||||||
*/
|
*/
|
||||||
deleteJournalEntry(entry) {
|
deleteJournalEntry(entry) {
|
||||||
const index = this.#entries.indexOf(entry);
|
const index = this.entries.indexOf(entry);
|
||||||
this.#entries.splice(index, 1);
|
this.entries.splice(index, 1);
|
||||||
this.updateTotal();
|
this.updateTotal();
|
||||||
|
this.currency.updateCodeSelectorStatus();
|
||||||
|
this.currency.form.updateMinDate();
|
||||||
this.#resetDeleteJournalEntryButtons();
|
this.#resetDeleteJournalEntryButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -553,11 +654,15 @@ class DebitCreditSideSubForm {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
#resetDeleteJournalEntryButtons() {
|
#resetDeleteJournalEntryButtons() {
|
||||||
if (this.#entries.length === 1) {
|
if (this.entries.length === 1) {
|
||||||
this.#entries[0].deleteButton.classList.add("d-none");
|
this.entries[0].deleteButton.classList.add("d-none");
|
||||||
} else {
|
} else {
|
||||||
for (const entry of this.#entries) {
|
for (const entry of this.entries) {
|
||||||
entry.deleteButton.classList.remove("d-none");
|
if (entry.isMatched) {
|
||||||
|
entry.deleteButton.classList.add("d-none");
|
||||||
|
} else {
|
||||||
|
entry.deleteButton.classList.remove("d-none");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -569,9 +674,10 @@ class DebitCreditSideSubForm {
|
|||||||
*/
|
*/
|
||||||
getTotal() {
|
getTotal() {
|
||||||
let total = new Decimal("0");
|
let total = new Decimal("0");
|
||||||
for (const entry of this.#entries) {
|
for (const entry of this.entries) {
|
||||||
if (entry.amount.value !== "") {
|
const amount = entry.getAmount();
|
||||||
total = total.plus(new Decimal(entry.amount.value));
|
if (amount !== null) {
|
||||||
|
total = total.plus(amount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
@ -582,17 +688,10 @@ class DebitCreditSideSubForm {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
updateTotal() {
|
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.#total.innerText = formatDecimal(this.getTotal());
|
||||||
this.currency.validateBalance();
|
this.currency.validateBalance();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the drag and drop reordering on the currency sub-forms.
|
* Initializes the drag and drop reordering on the currency sub-forms.
|
||||||
*
|
*
|
||||||
@ -600,22 +699,13 @@ class DebitCreditSideSubForm {
|
|||||||
#initializeDragAndDropReordering() {
|
#initializeDragAndDropReordering() {
|
||||||
initializeDragAndDropReordering(this.#entryList, () => {
|
initializeDragAndDropReordering(this.#entryList, () => {
|
||||||
const entryId = Array.from(this.#entryList.children).map((entry) => entry.id);
|
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));
|
this.entries.sort((a, b) => entryId.indexOf(a.element.id) - entryId.indexOf(b.element.id));
|
||||||
for (let i = 0; i < this.#entries.length; i++) {
|
for (let i = 0; i < this.entries.length; i++) {
|
||||||
this.#entries[i].no.value = String(i + 1);
|
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.
|
* Validates the form.
|
||||||
*
|
*
|
||||||
@ -624,7 +714,7 @@ class DebitCreditSideSubForm {
|
|||||||
validate() {
|
validate() {
|
||||||
let isValid = true;
|
let isValid = true;
|
||||||
isValid = this.#validateReal() && isValid;
|
isValid = this.#validateReal() && isValid;
|
||||||
for (const entry of this.#entries) {
|
for (const entry of this.entries) {
|
||||||
isValid = entry.validate() && isValid;
|
isValid = entry.validate() && isValid;
|
||||||
}
|
}
|
||||||
return isValid;
|
return isValid;
|
||||||
@ -636,7 +726,7 @@ class DebitCreditSideSubForm {
|
|||||||
* @returns {boolean} true if valid, or false otherwise
|
* @returns {boolean} true if valid, or false otherwise
|
||||||
*/
|
*/
|
||||||
#validateReal() {
|
#validateReal() {
|
||||||
if (this.#entries.length === 0) {
|
if (this.entries.length === 0) {
|
||||||
this.#element.classList.add("is-invalid");
|
this.#element.classList.add("is-invalid");
|
||||||
this.#error.innerText = A_("Please add some journal entries.");
|
this.#error.innerText = A_("Please add some journal entries.");
|
||||||
return false;
|
return false;
|
||||||
@ -677,6 +767,12 @@ class JournalEntrySubForm {
|
|||||||
*/
|
*/
|
||||||
entryIndex;
|
entryIndex;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this is an original entry with offsets
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
isMatched;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The prefix of the HTML ID and class
|
* The prefix of the HTML ID and class
|
||||||
* @type {string}
|
* @type {string}
|
||||||
@ -725,11 +821,29 @@ class JournalEntrySubForm {
|
|||||||
*/
|
*/
|
||||||
#summaryText;
|
#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
|
* The amount
|
||||||
* @type {HTMLInputElement}
|
* @type {HTMLInputElement}
|
||||||
*/
|
*/
|
||||||
amount;
|
#amount;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The text display of the amount
|
* The text display of the amount
|
||||||
@ -754,6 +868,7 @@ class JournalEntrySubForm {
|
|||||||
this.element = element;
|
this.element = element;
|
||||||
this.entryType = element.dataset.entryType;
|
this.entryType = element.dataset.entryType;
|
||||||
this.entryIndex = parseInt(element.dataset.entryIndex);
|
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.#prefix = "accounting-currency-" + element.dataset.currencyIndex + "-" + this.entryType + "-" + this.entryIndex;
|
||||||
this.#control = document.getElementById(this.#prefix + "-control");
|
this.#control = document.getElementById(this.#prefix + "-control");
|
||||||
this.#error = document.getElementById(this.#prefix + "-error");
|
this.#error = document.getElementById(this.#prefix + "-error");
|
||||||
@ -762,11 +877,14 @@ class JournalEntrySubForm {
|
|||||||
this.#accountText = document.getElementById(this.#prefix + "-account-text");
|
this.#accountText = document.getElementById(this.#prefix + "-account-text");
|
||||||
this.#summary = document.getElementById(this.#prefix + "-summary");
|
this.#summary = document.getElementById(this.#prefix + "-summary");
|
||||||
this.#summaryText = document.getElementById(this.#prefix + "-summary-text");
|
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.#amountText = document.getElementById(this.#prefix + "-amount-text");
|
||||||
this.deleteButton = document.getElementById(this.#prefix + "-delete");
|
this.deleteButton = document.getElementById(this.#prefix + "-delete");
|
||||||
this.#control.onclick = () => {
|
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.deleteButton.onclick = () => {
|
||||||
this.element.parentElement.removeChild(this.element);
|
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.
|
* Returns the account code.
|
||||||
*
|
*
|
||||||
@ -783,6 +928,15 @@ class JournalEntrySubForm {
|
|||||||
return this.#accountCode.value === ""? null: this.#accountCode.value;
|
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.
|
* Validates the form.
|
||||||
*
|
*
|
||||||
@ -794,7 +948,7 @@ class JournalEntrySubForm {
|
|||||||
this.#error.innerText = A_("Please select the account.");
|
this.#error.innerText = A_("Please select the account.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (this.amount.value === "") {
|
if (this.#amount.value === "") {
|
||||||
this.#control.classList.add("is-invalid");
|
this.#control.classList.add("is-invalid");
|
||||||
this.#error.innerText = A_("Please fill in the amount.");
|
this.#error.innerText = A_("Please fill in the amount.");
|
||||||
return false;
|
return false;
|
||||||
@ -807,21 +961,42 @@ class JournalEntrySubForm {
|
|||||||
/**
|
/**
|
||||||
* Stores the data into the journal entry sub-form.
|
* 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 accountCode {string} the account code
|
||||||
* @param accountText {string} the account text
|
* @param accountText {string} the account text
|
||||||
* @param summary {string} the summary
|
* @param summary {string} the summary
|
||||||
* @param amount {string} the amount
|
* @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.value = accountCode;
|
||||||
this.#accountCode.dataset.text = accountText;
|
this.#accountCode.dataset.text = accountText;
|
||||||
this.#accountText.innerText = accountText;
|
this.#accountText.innerText = accountText;
|
||||||
this.#summary.value = summary;
|
this.#summary.value = summary;
|
||||||
this.#summaryText.innerText = summary;
|
this.#summaryText.innerText = summary;
|
||||||
this.amount.value = amount;
|
this.#amount.value = amount;
|
||||||
this.#amountText.innerText = formatDecimal(new Decimal(amount));
|
this.#amountText.innerText = formatDecimal(new Decimal(amount));
|
||||||
this.validate();
|
this.validate();
|
||||||
this.side.updateTotal();
|
this.side.updateTotal();
|
||||||
|
this.side.currency.updateCodeSelectorStatus();
|
||||||
|
this.side.currency.form.updateMinDate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -35,19 +35,9 @@ First written: 2023/2/26
|
|||||||
|
|
||||||
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
|
<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>
|
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
|
||||||
{% for entry in currency.debit %}
|
{% with entries = currency.debit %}
|
||||||
<li class="list-group-item accounting-transaction-entry">
|
{% include "accounting/transaction/include/detail-entries.html" %}
|
||||||
<div class="d-flex justify-content-between">
|
{% endwith %}
|
||||||
<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 %}
|
|
||||||
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
|
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<div>{{ A_("Total") }}</div>
|
<div>{{ A_("Total") }}</div>
|
||||||
|
@ -21,15 +21,16 @@ First written: 2023/2/25
|
|||||||
#}
|
#}
|
||||||
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
|
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
|
||||||
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
|
<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 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="d-flex justify-content-between mt-2 mb-3">
|
||||||
<div class="form-floating accounting-currency-content">
|
<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() %}
|
{% for currency in accounting_currency_options() %}
|
||||||
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
|
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</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 id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -55,9 +56,17 @@ First written: 2023/2/25
|
|||||||
account_text = entry_form.account_text,
|
account_text = entry_form.account_text,
|
||||||
summary_data = entry_form.summary.data|accounting_default,
|
summary_data = entry_form.summary.data|accounting_default,
|
||||||
summary_errors = entry_form.summary.errors,
|
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_data = entry_form.amount.data|accounting_txn_format_amount_input,
|
||||||
amount_errors = entry_form.amount.errors,
|
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 %}
|
entry_errors = entry_form.all_errors %}
|
||||||
{% include "accounting/transaction/include/form-entry-item.html" %}
|
{% include "accounting/transaction/include/form-entry-item.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
@ -29,6 +29,7 @@ First written: 2023/2/25
|
|||||||
currency_errors = currency_form.whole_form.errors,
|
currency_errors = currency_form.whole_form.errors,
|
||||||
currency_code_data = currency_form.code.data,
|
currency_code_data = currency_form.code.data,
|
||||||
currency_code_errors = currency_form.code.errors,
|
currency_code_errors = currency_form.code.errors,
|
||||||
|
currency_code_is_locked = currency_form.is_code_locked,
|
||||||
debit_forms = currency_form.debit,
|
debit_forms = currency_form.debit,
|
||||||
debit_errors = currency_form.debit_errors,
|
debit_errors = currency_form.debit_errors,
|
||||||
debit_total = currency_form.form.debit_total|accounting_format_amount %}
|
debit_total = currency_form.form.debit_total|accounting_format_amount %}
|
||||||
|
@ -37,7 +37,7 @@ First written: 2023/2/25
|
|||||||
|
|
||||||
<ul id="accounting-account-selector-{{ entry_type }}-option-list" class="list-group accounting-selector-list">
|
<ul id="accounting-account-selector-{{ entry_type }}-option-list" class="list-group accounting-selector-list">
|
||||||
{% for account in account_options %}
|
{% 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 }}
|
{{ account }}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -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 #}
|
@ -42,10 +42,17 @@ First written: 2023/2/26
|
|||||||
</a>
|
</a>
|
||||||
{% if accounting_can_edit() %}
|
{% if accounting_can_edit() %}
|
||||||
{% block to_transfer %}{% endblock %}
|
{% block to_transfer %}{% endblock %}
|
||||||
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
|
{% if obj.can_delete %}
|
||||||
<i class="fa-solid fa-trash"></i>
|
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
|
||||||
{{ A_("Delete") }}
|
<i class="fa-solid fa-trash"></i>
|
||||||
</button>
|
{{ 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -57,7 +64,7 @@ First written: 2023/2/26
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if accounting_can_edit() %}
|
{% if accounting_can_edit() and obj.can_delete %}
|
||||||
<form action="{{ url_for("accounting.transaction.delete", txn=obj) }}" method="post">
|
<form action="{{ url_for("accounting.transaction.delete", txn=obj) }}" method="post">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
{% if request.args.next %}
|
{% if request.args.next %}
|
||||||
|
@ -20,19 +20,45 @@ Author: imacat@mail.imacat.idv.tw (imacat)
|
|||||||
First written: 2023/2/25
|
First written: 2023/2/25
|
||||||
#}
|
#}
|
||||||
{# <ul> For SonarQube not to complain about incorrect HTML #}
|
{# <ul> For SonarQube not to complain about incorrect HTML #}
|
||||||
<li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-entry accounting-currency-{{ currency_index }}-{{ entry_type }}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}">
|
<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 %}
|
{% if entry_id %}
|
||||||
<input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}">
|
<input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" value="{{ entry_index }}">
|
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" value="{{ entry_index }}">
|
||||||
|
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-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 }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}">
|
||||||
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" value="{{ summary_data }}">
|
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" value="{{ summary_data }}">
|
||||||
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" 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 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>
|
||||||
<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 }}-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 }}-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>
|
||||||
<div><span id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount-text" class="badge rounded-pill bg-primary">{{ amount_text }}</span></div>
|
<div><span id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount-text" class="badge rounded-pill bg-primary">{{ amount_text }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
@ -40,7 +66,7 @@ First written: 2023/2/25
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
<i class="fas fa-minus"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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/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/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/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>
|
<script src="{{ url_for("accounting.static", filename="js/summary-editor.js") }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -45,7 +46,7 @@ First written: 2023/2/26
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="form-floating mb-3">
|
<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>
|
<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 id="accounting-date-error" class="invalid-feedback">{% if form.date.errors %}{{ form.date.errors[0] }}{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -89,5 +90,6 @@ First written: 2023/2/26
|
|||||||
|
|
||||||
{% include "accounting/transaction/include/journal-entry-editor-modal.html" %}
|
{% include "accounting/transaction/include/journal-entry-editor-modal.html" %}
|
||||||
{% block form_modals %}{% endblock %}
|
{% block form_modals %}{% endblock %}
|
||||||
|
{% include "accounting/transaction/include/original-entry-selector-modal.html" %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -28,6 +28,22 @@ First written: 2023/2/25
|
|||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<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 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="">
|
<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>
|
<label class="form-label" for="accounting-entry-editor-summary">{{ A_("Summary") }}</label>
|
||||||
@ -45,7 +61,7 @@ First written: 2023/2/25
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-floating mb-3">
|
<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>
|
<label for="accounting-entry-editor-amount">{{ A_("Amount") }}</label>
|
||||||
<div id="accounting-entry-editor-amount-error" class="invalid-feedback"></div>
|
<div id="accounting-entry-editor-amount-error" class="invalid-feedback"></div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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>
|
||||||
|
|
@ -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>
|
<button class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal" aria-label="{{ A_("Close") }}"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<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">
|
<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>
|
</div>
|
||||||
|
|
||||||
{# Tab navigation #}
|
{# Tab navigation #}
|
||||||
@ -174,7 +177,7 @@ First written: 2023/2/28
|
|||||||
{# The suggested accounts #}
|
{# The suggested accounts #}
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
{% for account in summary_editor.accounts %}
|
{% 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 }}
|
{{ account }}
|
||||||
</button>
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -35,19 +35,9 @@ First written: 2023/2/26
|
|||||||
|
|
||||||
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
|
<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>
|
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
|
||||||
{% for entry in currency.credit %}
|
{% with entries = currency.credit %}
|
||||||
<li class="list-group-item accounting-transaction-entry">
|
{% include "accounting/transaction/include/detail-entries.html" %}
|
||||||
<div class="d-flex justify-content-between">
|
{% endwith %}
|
||||||
<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 %}
|
|
||||||
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
|
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<div>{{ A_("Total") }}</div>
|
<div>{{ A_("Total") }}</div>
|
||||||
|
@ -21,15 +21,16 @@ First written: 2023/2/25
|
|||||||
#}
|
#}
|
||||||
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
|
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
|
||||||
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
|
<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 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="d-flex justify-content-between mt-2 mb-3">
|
||||||
<div class="form-floating accounting-currency-content">
|
<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() %}
|
{% for currency in accounting_currency_options() %}
|
||||||
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
|
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</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 id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -55,9 +56,17 @@ First written: 2023/2/25
|
|||||||
account_text = entry_form.account_text,
|
account_text = entry_form.account_text,
|
||||||
summary_data = entry_form.summary.data|accounting_default,
|
summary_data = entry_form.summary.data|accounting_default,
|
||||||
summary_errors = entry_form.summary.errors,
|
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_data = entry_form.amount.data|accounting_txn_format_amount_input,
|
||||||
amount_errors = entry_form.amount.errors,
|
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 %}
|
entry_errors = entry_form.all_errors %}
|
||||||
{% include "accounting/transaction/include/form-entry-item.html" %}
|
{% include "accounting/transaction/include/form-entry-item.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
@ -29,6 +29,7 @@ First written: 2023/2/25
|
|||||||
currency_errors = currency_form.whole_form.errors,
|
currency_errors = currency_form.whole_form.errors,
|
||||||
currency_code_data = currency_form.code.data,
|
currency_code_data = currency_form.code.data,
|
||||||
currency_code_errors = currency_form.code.errors,
|
currency_code_errors = currency_form.code.errors,
|
||||||
|
currency_code_is_locked = currency_form.is_code_locked,
|
||||||
credit_forms = currency_form.credit,
|
credit_forms = currency_form.credit,
|
||||||
credit_errors = currency_form.credit_errors,
|
credit_errors = currency_form.credit_errors,
|
||||||
credit_total = currency_form.form.credit_total|accounting_format_amount %}
|
credit_total = currency_form.form.credit_total|accounting_format_amount %}
|
||||||
|
@ -31,19 +31,9 @@ First written: 2023/2/26
|
|||||||
<div class="col-sm-6 mb-2">
|
<div class="col-sm-6 mb-2">
|
||||||
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
|
<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>
|
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Debit") }}</li>
|
||||||
{% for entry in currency.debit %}
|
{% with entries = currency.debit %}
|
||||||
<li class="list-group-item accounting-transaction-entry">
|
{% include "accounting/transaction/include/detail-entries.html" %}
|
||||||
<div class="d-flex justify-content-between">
|
{% endwith %}
|
||||||
<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 %}
|
|
||||||
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
|
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<div>{{ A_("Total") }}</div>
|
<div>{{ A_("Total") }}</div>
|
||||||
@ -57,19 +47,9 @@ First written: 2023/2/26
|
|||||||
<div class="col-sm-6 mb-2">
|
<div class="col-sm-6 mb-2">
|
||||||
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
|
<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>
|
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Credit") }}</li>
|
||||||
{% for entry in currency.credit %}
|
{% with entries = currency.credit %}
|
||||||
<li class="list-group-item accounting-transaction-entry">
|
{% include "accounting/transaction/include/detail-entries.html" %}
|
||||||
<div class="d-flex justify-content-between">
|
{% endwith %}
|
||||||
<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 %}
|
|
||||||
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
|
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<div>{{ A_("Total") }}</div>
|
<div>{{ A_("Total") }}</div>
|
||||||
|
@ -21,15 +21,16 @@ First written: 2023/2/25
|
|||||||
#}
|
#}
|
||||||
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
|
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
|
||||||
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
|
<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 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="d-flex justify-content-between mt-2 mb-3">
|
||||||
<div class="form-floating accounting-currency-content">
|
<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() %}
|
{% for currency in accounting_currency_options() %}
|
||||||
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
|
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</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 id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -57,9 +58,17 @@ First written: 2023/2/25
|
|||||||
account_text = entry_form.account_text,
|
account_text = entry_form.account_text,
|
||||||
summary_data = entry_form.summary.data|accounting_default,
|
summary_data = entry_form.summary.data|accounting_default,
|
||||||
summary_errors = entry_form.summary.errors,
|
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_data = entry_form.amount.data|accounting_txn_format_amount_input,
|
||||||
amount_errors = entry_form.amount.errors,
|
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 %}
|
entry_errors = entry_form.all_errors %}
|
||||||
{% include "accounting/transaction/include/form-entry-item.html" %}
|
{% include "accounting/transaction/include/form-entry-item.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
@ -97,9 +106,17 @@ First written: 2023/2/25
|
|||||||
account_text = entry_form.account_text,
|
account_text = entry_form.account_text,
|
||||||
summary_data = entry_form.summary.data|accounting_default,
|
summary_data = entry_form.summary.data|accounting_default,
|
||||||
summary_errors = entry_form.summary.errors,
|
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_data = entry_form.amount.data|accounting_txn_format_amount_input,
|
||||||
amount_errors = entry_form.amount.errors,
|
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 %}
|
entry_errors = entry_form.all_errors %}
|
||||||
{% include "accounting/transaction/include/form-entry-item.html" %}
|
{% include "accounting/transaction/include/form-entry-item.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
@ -29,6 +29,7 @@ First written: 2023/2/25
|
|||||||
currency_errors = currency_form.whole_form.errors,
|
currency_errors = currency_form.whole_form.errors,
|
||||||
currency_code_data = currency_form.code.data,
|
currency_code_data = currency_form.code.data,
|
||||||
currency_code_errors = currency_form.code.errors,
|
currency_code_errors = currency_form.code.errors,
|
||||||
|
currency_code_is_locked = currency_form.is_code_locked,
|
||||||
debit_forms = currency_form.debit,
|
debit_forms = currency_form.debit,
|
||||||
debit_errors = currency_form.debit_errors,
|
debit_errors = currency_form.debit_errors,
|
||||||
debit_total = currency_form.form.debit_total|accounting_format_amount,
|
debit_total = currency_form.form.debit_total|accounting_format_amount,
|
||||||
|
@ -20,10 +20,10 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from flask import abort
|
from flask import abort
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
from werkzeug.routing import BaseConverter
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
from accounting import db
|
from accounting.models import Transaction, JournalEntry
|
||||||
from accounting.models import Transaction
|
|
||||||
from accounting.utils.txn_types import TransactionType
|
from accounting.utils.txn_types import TransactionType
|
||||||
|
|
||||||
|
|
||||||
@ -37,7 +37,13 @@ class TransactionConverter(BaseConverter):
|
|||||||
:param value: The transaction ID.
|
:param value: The transaction ID.
|
||||||
:return: The corresponding transaction.
|
: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:
|
if transaction is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
return transaction
|
return transaction
|
||||||
|
@ -19,6 +19,7 @@
|
|||||||
"""
|
"""
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
from flask_babel import LazyString
|
from flask_babel import LazyString
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField, ValidationError, FieldList, IntegerField, \
|
from wtforms import StringField, ValidationError, FieldList, IntegerField, \
|
||||||
@ -27,9 +28,11 @@ from wtforms.validators import DataRequired
|
|||||||
|
|
||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.locale import lazy_gettext
|
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 accounting.utils.strip_text import strip_text
|
||||||
from .journal_entry import CreditEntryForm, DebitEntryForm
|
from .journal_entry import JournalEntryForm, CreditEntryForm, DebitEntryForm
|
||||||
|
|
||||||
CURRENCY_REQUIRED: DataRequired = DataRequired(
|
CURRENCY_REQUIRED: DataRequired = DataRequired(
|
||||||
lazy_gettext("Please select the currency."))
|
lazy_gettext("Please select the currency."))
|
||||||
@ -47,6 +50,50 @@ class CurrencyExists:
|
|||||||
"The currency does not exist."))
|
"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:
|
class NeedSomeJournalEntries:
|
||||||
"""The validator to check if there is any journal entry sub-form."""
|
"""The validator to check if there is any journal entry sub-form."""
|
||||||
|
|
||||||
@ -78,6 +125,41 @@ class CurrencyForm(FlaskForm):
|
|||||||
whole_form = BooleanField()
|
whole_form = BooleanField()
|
||||||
"""The pseudo field for the whole form validators."""
|
"""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):
|
class IncomeCurrencyForm(CurrencyForm):
|
||||||
"""The form to create or edit a currency in a cash income transaction."""
|
"""The form to create or edit a currency in a cash income transaction."""
|
||||||
@ -86,7 +168,9 @@ class IncomeCurrencyForm(CurrencyForm):
|
|||||||
code = StringField(
|
code = StringField(
|
||||||
filters=[strip_text],
|
filters=[strip_text],
|
||||||
validators=[CURRENCY_REQUIRED,
|
validators=[CURRENCY_REQUIRED,
|
||||||
CurrencyExists()])
|
CurrencyExists(),
|
||||||
|
SameCurrencyAsOriginalEntries(),
|
||||||
|
KeepCurrencyWhenHavingOffset()])
|
||||||
"""The currency code."""
|
"""The currency code."""
|
||||||
credit = FieldList(FormField(CreditEntryForm),
|
credit = FieldList(FormField(CreditEntryForm),
|
||||||
validators=[NeedSomeJournalEntries()])
|
validators=[NeedSomeJournalEntries()])
|
||||||
@ -121,7 +205,9 @@ class ExpenseCurrencyForm(CurrencyForm):
|
|||||||
code = StringField(
|
code = StringField(
|
||||||
filters=[strip_text],
|
filters=[strip_text],
|
||||||
validators=[CURRENCY_REQUIRED,
|
validators=[CURRENCY_REQUIRED,
|
||||||
CurrencyExists()])
|
CurrencyExists(),
|
||||||
|
SameCurrencyAsOriginalEntries(),
|
||||||
|
KeepCurrencyWhenHavingOffset()])
|
||||||
"""The currency code."""
|
"""The currency code."""
|
||||||
debit = FieldList(FormField(DebitEntryForm),
|
debit = FieldList(FormField(DebitEntryForm),
|
||||||
validators=[NeedSomeJournalEntries()])
|
validators=[NeedSomeJournalEntries()])
|
||||||
@ -156,7 +242,9 @@ class TransferCurrencyForm(CurrencyForm):
|
|||||||
code = StringField(
|
code = StringField(
|
||||||
filters=[strip_text],
|
filters=[strip_text],
|
||||||
validators=[CURRENCY_REQUIRED,
|
validators=[CURRENCY_REQUIRED,
|
||||||
CurrencyExists()])
|
CurrencyExists(),
|
||||||
|
SameCurrencyAsOriginalEntries(),
|
||||||
|
KeepCurrencyWhenHavingOffset()])
|
||||||
"""The currency code."""
|
"""The currency code."""
|
||||||
debit = FieldList(FormField(DebitEntryForm),
|
debit = FieldList(FormField(DebitEntryForm),
|
||||||
validators=[NeedSomeJournalEntries()])
|
validators=[NeedSomeJournalEntries()])
|
||||||
|
@ -18,14 +18,21 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
from flask_babel import LazyString
|
from flask_babel import LazyString
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
from wtforms import StringField, ValidationError, DecimalField, IntegerField
|
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.locale import lazy_gettext
|
||||||
from accounting.models import Account, JournalEntry
|
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.random_id import new_id
|
||||||
from accounting.utils.strip_text import strip_text
|
from accounting.utils.strip_text import strip_text
|
||||||
from accounting.utils.user import get_current_user_pk
|
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."""
|
"""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:
|
class AccountExists:
|
||||||
"""The validator to check if the account exists."""
|
"""The validator to check if the account exists."""
|
||||||
|
|
||||||
@ -74,6 +141,72 @@ class IsCreditAccount:
|
|||||||
"This account is not for credit entries."))
|
"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:
|
class PositiveAmount:
|
||||||
"""The validator to check if the amount is positive."""
|
"""The validator to check if the amount is positive."""
|
||||||
|
|
||||||
@ -85,17 +218,86 @@ class PositiveAmount:
|
|||||||
"Please fill in a positive amount."))
|
"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):
|
class JournalEntryForm(FlaskForm):
|
||||||
"""The base form to create or edit a journal entry."""
|
"""The base form to create or edit a journal entry."""
|
||||||
eid = IntegerField()
|
eid = IntegerField()
|
||||||
"""The existing journal entry ID."""
|
"""The existing journal entry ID."""
|
||||||
no = IntegerField()
|
no = IntegerField()
|
||||||
"""The order in the currency."""
|
"""The order in the currency."""
|
||||||
|
original_entry_id = IntegerField()
|
||||||
|
"""The Id of the original entry."""
|
||||||
account_code = StringField()
|
account_code = StringField()
|
||||||
"""The account code."""
|
"""The account code."""
|
||||||
amount = DecimalField()
|
amount = DecimalField()
|
||||||
"""The amount."""
|
"""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
|
@property
|
||||||
def account_text(self) -> str:
|
def account_text(self) -> str:
|
||||||
"""Returns the text representation of the account.
|
"""Returns the text representation of the account.
|
||||||
@ -109,6 +311,124 @@ class JournalEntryForm(FlaskForm):
|
|||||||
return ""
|
return ""
|
||||||
return str(account)
|
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
|
@property
|
||||||
def all_errors(self) -> list[str | LazyString]:
|
def all_errors(self) -> list[str | LazyString]:
|
||||||
"""Returns all the errors of the form.
|
"""Returns all the errors of the form.
|
||||||
@ -128,15 +448,30 @@ class DebitEntryForm(JournalEntryForm):
|
|||||||
"""The existing journal entry ID."""
|
"""The existing journal entry ID."""
|
||||||
no = IntegerField()
|
no = IntegerField()
|
||||||
"""The order in the currency."""
|
"""The order in the currency."""
|
||||||
|
original_entry_id = IntegerField(
|
||||||
|
validators=[Optional(),
|
||||||
|
OriginalEntryExists(),
|
||||||
|
OriginalEntryOppositeSide(),
|
||||||
|
OriginalEntryNeedOffset(),
|
||||||
|
OriginalEntryNotOffset()])
|
||||||
|
"""The Id of the original entry."""
|
||||||
account_code = StringField(
|
account_code = StringField(
|
||||||
filters=[strip_text],
|
filters=[strip_text],
|
||||||
validators=[ACCOUNT_REQUIRED,
|
validators=[ACCOUNT_REQUIRED,
|
||||||
AccountExists(),
|
AccountExists(),
|
||||||
IsDebitAccount()])
|
IsDebitAccount(),
|
||||||
|
SameAccountAsOriginalEntry(),
|
||||||
|
KeepAccountWhenHavingOffset(),
|
||||||
|
NotStartPayableFromDebit()])
|
||||||
"""The account code."""
|
"""The account code."""
|
||||||
|
offset_original_entry_id = IntegerField()
|
||||||
|
"""The Id of the original entry."""
|
||||||
summary = StringField(filters=[strip_text])
|
summary = StringField(filters=[strip_text])
|
||||||
"""The summary."""
|
"""The summary."""
|
||||||
amount = DecimalField(validators=[PositiveAmount()])
|
amount = DecimalField(
|
||||||
|
validators=[PositiveAmount(),
|
||||||
|
NotExceedingOriginalEntryNetBalance(),
|
||||||
|
NotLessThanOffsetTotal()])
|
||||||
"""The amount."""
|
"""The amount."""
|
||||||
|
|
||||||
def populate_obj(self, obj: JournalEntry) -> None:
|
def populate_obj(self, obj: JournalEntry) -> None:
|
||||||
@ -148,6 +483,7 @@ class DebitEntryForm(JournalEntryForm):
|
|||||||
is_new: bool = obj.id is None
|
is_new: bool = obj.id is None
|
||||||
if is_new:
|
if is_new:
|
||||||
obj.id = new_id(JournalEntry)
|
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.account_id = Account.find_by_code(self.account_code.data).id
|
||||||
obj.summary = self.summary.data
|
obj.summary = self.summary.data
|
||||||
obj.is_debit = True
|
obj.is_debit = True
|
||||||
@ -164,15 +500,28 @@ class CreditEntryForm(JournalEntryForm):
|
|||||||
"""The existing journal entry ID."""
|
"""The existing journal entry ID."""
|
||||||
no = IntegerField()
|
no = IntegerField()
|
||||||
"""The order in the currency."""
|
"""The order in the currency."""
|
||||||
|
original_entry_id = IntegerField(
|
||||||
|
validators=[Optional(),
|
||||||
|
OriginalEntryExists(),
|
||||||
|
OriginalEntryOppositeSide(),
|
||||||
|
OriginalEntryNeedOffset(),
|
||||||
|
OriginalEntryNotOffset()])
|
||||||
|
"""The Id of the original entry."""
|
||||||
account_code = StringField(
|
account_code = StringField(
|
||||||
filters=[strip_text],
|
filters=[strip_text],
|
||||||
validators=[ACCOUNT_REQUIRED,
|
validators=[ACCOUNT_REQUIRED,
|
||||||
AccountExists(),
|
AccountExists(),
|
||||||
IsCreditAccount()])
|
IsCreditAccount(),
|
||||||
|
SameAccountAsOriginalEntry(),
|
||||||
|
KeepAccountWhenHavingOffset(),
|
||||||
|
NotStartReceivableFromCredit()])
|
||||||
"""The account code."""
|
"""The account code."""
|
||||||
summary = StringField(filters=[strip_text])
|
summary = StringField(filters=[strip_text])
|
||||||
"""The summary."""
|
"""The summary."""
|
||||||
amount = DecimalField(validators=[PositiveAmount()])
|
amount = DecimalField(
|
||||||
|
validators=[PositiveAmount(),
|
||||||
|
NotExceedingOriginalEntryNetBalance(),
|
||||||
|
NotLessThanOffsetTotal()])
|
||||||
"""The amount."""
|
"""The amount."""
|
||||||
|
|
||||||
def populate_obj(self, obj: JournalEntry) -> None:
|
def populate_obj(self, obj: JournalEntry) -> None:
|
||||||
@ -184,6 +533,7 @@ class CreditEntryForm(JournalEntryForm):
|
|||||||
is_new: bool = obj.id is None
|
is_new: bool = obj.id is None
|
||||||
if is_new:
|
if is_new:
|
||||||
obj.id = new_id(JournalEntry)
|
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.account_id = Account.find_by_code(self.account_code.data).id
|
||||||
obj.summary = self.summary.data
|
obj.summary = self.summary.data
|
||||||
obj.is_debit = False
|
obj.is_debit = False
|
||||||
|
@ -19,13 +19,14 @@
|
|||||||
"""
|
"""
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.models import Transaction
|
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
|
"""Sorts the transactions under a date after changing the date or deleting
|
||||||
a transaction.
|
a transaction.
|
||||||
|
|
||||||
@ -33,9 +34,11 @@ def sort_transactions_in(txn_date: date, exclude: int) -> None:
|
|||||||
:param exclude: The transaction ID to exclude.
|
:param exclude: The transaction ID to exclude.
|
||||||
:return: None.
|
: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\
|
transactions: list[Transaction] = Transaction.query\
|
||||||
.filter(Transaction.date == txn_date,
|
.filter(*conditions)\
|
||||||
Transaction.id != exclude)\
|
|
||||||
.order_by(Transaction.no).all()
|
.order_by(Transaction.no).all()
|
||||||
for i in range(len(transactions)):
|
for i in range(len(transactions)):
|
||||||
if transactions[i].no != i + 1:
|
if transactions[i].no != i + 1:
|
||||||
|
@ -17,14 +17,15 @@
|
|||||||
"""The transaction forms for the transaction management.
|
"""The transaction forms for the transaction management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
import datetime as dt
|
||||||
import typing as t
|
import typing as t
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask_babel import LazyString
|
from flask_babel import LazyString
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import DateField, FieldList, FormField, \
|
from wtforms import DateField, FieldList, FormField, TextAreaField, \
|
||||||
TextAreaField
|
BooleanField
|
||||||
from wtforms.validators import DataRequired, ValidationError
|
from wtforms.validators import DataRequired, ValidationError
|
||||||
|
|
||||||
from accounting import db
|
from accounting import db
|
||||||
@ -32,6 +33,8 @@ from accounting.locale import lazy_gettext
|
|||||||
from accounting.models import Transaction, Account, JournalEntry, \
|
from accounting.models import Transaction, Account, JournalEntry, \
|
||||||
TransactionCurrency
|
TransactionCurrency
|
||||||
from accounting.transaction.utils.account_option import AccountOption
|
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.transaction.utils.summary_editor import SummaryEditor
|
||||||
from accounting.utils.random_id import new_id
|
from accounting.utils.random_id import new_id
|
||||||
from accounting.utils.strip_text import strip_multiline_text
|
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."""
|
"""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:
|
class NeedSomeCurrencies:
|
||||||
"""The validator to check if there is any currency sub-form."""
|
"""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."))
|
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):
|
class TransactionForm(FlaskForm):
|
||||||
"""The base form to create or edit a transaction."""
|
"""The base form to create or edit a transaction."""
|
||||||
date = DateField()
|
date = DateField()
|
||||||
@ -76,6 +127,19 @@ class TransactionForm(FlaskForm):
|
|||||||
"""The journal entry collector. The default is the base abstract
|
"""The journal entry collector. The default is the base abstract
|
||||||
collector only to provide the correct type. The subclass forms should
|
collector only to provide the correct type. The subclass forms should
|
||||||
provide their own collectors."""
|
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:
|
def populate_obj(self, obj: Transaction) -> None:
|
||||||
"""Populates the form data into a transaction object.
|
"""Populates the form data into a transaction object.
|
||||||
@ -86,6 +150,7 @@ class TransactionForm(FlaskForm):
|
|||||||
is_new: bool = obj.id is None
|
is_new: bool = obj.id is None
|
||||||
if is_new:
|
if is_new:
|
||||||
obj.id = new_id(Transaction)
|
obj.id = new_id(Transaction)
|
||||||
|
self.date: DateField
|
||||||
self.__set_date(obj, self.date.data)
|
self.__set_date(obj, self.date.data)
|
||||||
obj.note = self.note.data
|
obj.note = self.note.data
|
||||||
|
|
||||||
@ -107,8 +172,18 @@ class TransactionForm(FlaskForm):
|
|||||||
obj.created_by_id = current_user_pk
|
obj.created_by_id = current_user_pk
|
||||||
obj.updated_by_id = current_user_pk
|
obj.updated_by_id = current_user_pk
|
||||||
|
|
||||||
@staticmethod
|
@property
|
||||||
def __set_date(obj: Transaction, new_date: date) -> None:
|
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.
|
"""Sets the transaction date and number.
|
||||||
|
|
||||||
:param obj: The transaction object.
|
:param obj: The transaction object.
|
||||||
@ -118,11 +193,23 @@ class TransactionForm(FlaskForm):
|
|||||||
if obj.date is None or obj.date != new_date:
|
if obj.date is None or obj.date != new_date:
|
||||||
if obj.date is not None:
|
if obj.date is not None:
|
||||||
sort_transactions_in(obj.date, obj.id)
|
sort_transactions_in(obj.date, obj.id)
|
||||||
sort_transactions_in(new_date, obj.id)
|
if self.max_date is not None and new_date == self.max_date:
|
||||||
count: int = Transaction.query\
|
db_min_no: int | None = db.session.scalar(
|
||||||
.filter(Transaction.date == new_date).count()
|
sa.select(sa.func.min(Transaction.no))
|
||||||
obj.date = new_date
|
.filter(Transaction.date == new_date))
|
||||||
obj.no = count + 1
|
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()
|
||||||
|
obj.date = new_date
|
||||||
|
obj.no = count + 1
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def debit_account_options(self) -> list[AccountOption]:
|
def debit_account_options(self) -> list[AccountOption]:
|
||||||
@ -131,7 +218,8 @@ class TransactionForm(FlaskForm):
|
|||||||
:return: The selectable debit accounts.
|
:return: The selectable debit accounts.
|
||||||
"""
|
"""
|
||||||
accounts: list[AccountOption] \
|
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(
|
in_use: set[int] = set(db.session.scalars(
|
||||||
sa.select(JournalEntry.account_id)
|
sa.select(JournalEntry.account_id)
|
||||||
.filter(JournalEntry.is_debit)
|
.filter(JournalEntry.is_debit)
|
||||||
@ -147,7 +235,8 @@ class TransactionForm(FlaskForm):
|
|||||||
:return: The selectable credit accounts.
|
:return: The selectable credit accounts.
|
||||||
"""
|
"""
|
||||||
accounts: list[AccountOption] \
|
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(
|
in_use: set[int] = set(db.session.scalars(
|
||||||
sa.select(JournalEntry.account_id)
|
sa.select(JournalEntry.account_id)
|
||||||
.filter(sa.not_(JournalEntry.is_debit))
|
.filter(sa.not_(JournalEntry.is_debit))
|
||||||
@ -173,6 +262,46 @@ class TransactionForm(FlaskForm):
|
|||||||
"""
|
"""
|
||||||
return SummaryEditor()
|
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)
|
T = t.TypeVar("T", bound=TransactionForm)
|
||||||
"""A transaction form variant."""
|
"""A transaction form variant."""
|
||||||
@ -314,16 +443,23 @@ class JournalEntryCollector(t.Generic[T], ABC):
|
|||||||
|
|
||||||
class IncomeTransactionForm(TransactionForm):
|
class IncomeTransactionForm(TransactionForm):
|
||||||
"""The form to create or edit a cash income transaction."""
|
"""The form to create or edit a cash income transaction."""
|
||||||
date = DateField(validators=[DATE_REQUIRED])
|
date = DateField(
|
||||||
|
validators=[DATE_REQUIRED,
|
||||||
|
NotBeforeOriginalEntries(),
|
||||||
|
NotAfterOffsetEntries()])
|
||||||
"""The date."""
|
"""The date."""
|
||||||
currencies = FieldList(FormField(IncomeCurrencyForm), name="currency",
|
currencies = FieldList(FormField(IncomeCurrencyForm), name="currency",
|
||||||
validators=[NeedSomeCurrencies()])
|
validators=[NeedSomeCurrencies()])
|
||||||
"""The journal entries categorized by their currencies."""
|
"""The journal entries categorized by their currencies."""
|
||||||
note = TextAreaField(filters=[strip_multiline_text])
|
note = TextAreaField(filters=[strip_multiline_text])
|
||||||
"""The note."""
|
"""The note."""
|
||||||
|
whole_form = BooleanField(
|
||||||
|
validators=[CannotDeleteOriginalEntriesWithOffset()])
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
self._is_receivable_needed = True
|
||||||
|
|
||||||
class Collector(JournalEntryCollector[IncomeTransactionForm]):
|
class Collector(JournalEntryCollector[IncomeTransactionForm]):
|
||||||
"""The journal entry collector for the cash income transactions."""
|
"""The journal entry collector for the cash income transactions."""
|
||||||
@ -352,16 +488,23 @@ class IncomeTransactionForm(TransactionForm):
|
|||||||
|
|
||||||
class ExpenseTransactionForm(TransactionForm):
|
class ExpenseTransactionForm(TransactionForm):
|
||||||
"""The form to create or edit a cash expense transaction."""
|
"""The form to create or edit a cash expense transaction."""
|
||||||
date = DateField(validators=[DATE_REQUIRED])
|
date = DateField(
|
||||||
|
validators=[DATE_REQUIRED,
|
||||||
|
NotBeforeOriginalEntries(),
|
||||||
|
NotAfterOffsetEntries()])
|
||||||
"""The date."""
|
"""The date."""
|
||||||
currencies = FieldList(FormField(ExpenseCurrencyForm), name="currency",
|
currencies = FieldList(FormField(ExpenseCurrencyForm), name="currency",
|
||||||
validators=[NeedSomeCurrencies()])
|
validators=[NeedSomeCurrencies()])
|
||||||
"""The journal entries categorized by their currencies."""
|
"""The journal entries categorized by their currencies."""
|
||||||
note = TextAreaField(filters=[strip_multiline_text])
|
note = TextAreaField(filters=[strip_multiline_text])
|
||||||
"""The note."""
|
"""The note."""
|
||||||
|
whole_form = BooleanField(
|
||||||
|
validators=[CannotDeleteOriginalEntriesWithOffset()])
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
self._is_payable_needed = True
|
||||||
|
|
||||||
class Collector(JournalEntryCollector[ExpenseTransactionForm]):
|
class Collector(JournalEntryCollector[ExpenseTransactionForm]):
|
||||||
"""The journal entry collector for the cash expense
|
"""The journal entry collector for the cash expense
|
||||||
@ -391,16 +534,24 @@ class ExpenseTransactionForm(TransactionForm):
|
|||||||
|
|
||||||
class TransferTransactionForm(TransactionForm):
|
class TransferTransactionForm(TransactionForm):
|
||||||
"""The form to create or edit a transfer transaction."""
|
"""The form to create or edit a transfer transaction."""
|
||||||
date = DateField(validators=[DATE_REQUIRED])
|
date = DateField(
|
||||||
|
validators=[DATE_REQUIRED,
|
||||||
|
NotBeforeOriginalEntries(),
|
||||||
|
NotAfterOffsetEntries()])
|
||||||
"""The date."""
|
"""The date."""
|
||||||
currencies = FieldList(FormField(TransferCurrencyForm), name="currency",
|
currencies = FieldList(FormField(TransferCurrencyForm), name="currency",
|
||||||
validators=[NeedSomeCurrencies()])
|
validators=[NeedSomeCurrencies()])
|
||||||
"""The journal entries categorized by their currencies."""
|
"""The journal entries categorized by their currencies."""
|
||||||
note = TextAreaField(filters=[strip_multiline_text])
|
note = TextAreaField(filters=[strip_multiline_text])
|
||||||
"""The note."""
|
"""The note."""
|
||||||
|
whole_form = BooleanField(
|
||||||
|
validators=[CannotDeleteOriginalEntriesWithOffset()])
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
self._is_payable_needed = True
|
||||||
|
self._is_receivable_needed = True
|
||||||
|
|
||||||
class Collector(JournalEntryCollector[TransferTransactionForm]):
|
class Collector(JournalEntryCollector[TransferTransactionForm]):
|
||||||
"""The journal entry collector for the transfer transactions."""
|
"""The journal entry collector for the transfer transactions."""
|
||||||
|
@ -38,6 +38,10 @@ class AccountOption:
|
|||||||
"""The string representation of the account option."""
|
"""The string representation of the account option."""
|
||||||
self.is_in_use: bool = False
|
self.is_in_use: bool = False
|
||||||
"""True if this account is in use, or False otherwise."""
|
"""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:
|
def __str__(self) -> str:
|
||||||
"""Returns the string representation of the account option.
|
"""Returns the string representation of the account option.
|
||||||
|
39
src/accounting/transaction/utils/offset_alias.py
Normal file
39
src/accounting/transaction/utils/offset_alias.py
Normal 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"))
|
82
src/accounting/transaction/utils/original_entries.py
Normal file
82
src/accounting/transaction/utils/original_entries.py
Normal 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
|
@ -218,7 +218,8 @@ class SummaryEditor:
|
|||||||
JournalEntry.account_id,
|
JournalEntry.account_id,
|
||||||
sa.func.count().label("freq"))\
|
sa.func.count().label("freq"))\
|
||||||
.filter(JournalEntry.summary.is_not(None),
|
.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)
|
.group_by(entry_type, tag_type, tag, JournalEntry.account_id)
|
||||||
result: list[sa.Row] = db.session.execute(select).all()
|
result: list[sa.Row] = db.session.execute(select).all()
|
||||||
accounts: dict[int, Account] \
|
accounts: dict[int, Account] \
|
||||||
|
@ -117,6 +117,7 @@ def show_transaction_edit_form(txn: Transaction) -> str:
|
|||||||
if "form" in session:
|
if "form" in session:
|
||||||
form = txn_op.form(ImmutableMultiDict(parse_qsl(session["form"])))
|
form = txn_op.form(ImmutableMultiDict(parse_qsl(session["form"])))
|
||||||
del session["form"]
|
del session["form"]
|
||||||
|
form.obj = txn
|
||||||
form.validate()
|
form.validate()
|
||||||
else:
|
else:
|
||||||
form = txn_op.form(obj=txn)
|
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)
|
txn_op: TransactionOperator = get_txn_op(txn, is_check_as=True)
|
||||||
form: txn_op.form = txn_op.form(request.form)
|
form: txn_op.form = txn_op.form(request.form)
|
||||||
|
form.obj = txn
|
||||||
if not form.validate():
|
if not form.validate():
|
||||||
flash_form_errors(form)
|
flash_form_errors(form)
|
||||||
session["form"] = urlencode(list(request.form.items()))
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
|
656
tests/test_offset.py
Normal file
656
tests/test_offset.py
Normal 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)
|
@ -229,6 +229,15 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
|
|||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"], create_uri)
|
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
|
# Negative amount
|
||||||
form = self.__get_add_form()
|
form = self.__get_add_form()
|
||||||
set_negative_amount(form)
|
set_negative_amount(form)
|
||||||
@ -380,6 +389,15 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
|
|||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"], edit_uri)
|
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
|
# Negative amount
|
||||||
form: dict[str, str] = form_0.copy()
|
form: dict[str, str] = form_0.copy()
|
||||||
set_negative_amount(form)
|
set_negative_amount(form)
|
||||||
@ -781,6 +799,15 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
|
|||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"], create_uri)
|
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
|
# Negative amount
|
||||||
form = self.__get_add_form()
|
form = self.__get_add_form()
|
||||||
set_negative_amount(form)
|
set_negative_amount(form)
|
||||||
@ -935,6 +962,15 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
|
|||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"], edit_uri)
|
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
|
# Negative amount
|
||||||
form: dict[str, str] = form_0.copy()
|
form: dict[str, str] = form_0.copy()
|
||||||
set_negative_amount(form)
|
set_negative_amount(form)
|
||||||
@ -1356,6 +1392,24 @@ class TransferTransactionTestCase(unittest.TestCase):
|
|||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"], create_uri)
|
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
|
# Negative amount
|
||||||
form = self.__get_add_form()
|
form = self.__get_add_form()
|
||||||
set_negative_amount(form)
|
set_negative_amount(form)
|
||||||
@ -1537,6 +1591,24 @@ class TransferTransactionTestCase(unittest.TestCase):
|
|||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"], edit_uri)
|
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
|
# Negative amount
|
||||||
form: dict[str, str] = form_0.copy()
|
form: dict[str, str] = form_0.copy()
|
||||||
set_negative_amount(form)
|
set_negative_amount(form)
|
||||||
|
307
tests/testlib_offset.py
Normal file
307
tests/testlib_offset.py
Normal 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
|
@ -40,7 +40,10 @@ class Accounts:
|
|||||||
CASH: str = "1111-001"
|
CASH: str = "1111-001"
|
||||||
PETTY_CASH: str = "1112-001"
|
PETTY_CASH: str = "1112-001"
|
||||||
BANK: str = "1113-001"
|
BANK: str = "1113-001"
|
||||||
|
NOTES_RECEIVABLE: str = "1131-001"
|
||||||
|
RECEIVABLE: str = "1141-001"
|
||||||
PREPAID: str = "1258-001"
|
PREPAID: str = "1258-001"
|
||||||
|
NOTES_PAYABLE: str = "2131-001"
|
||||||
PAYABLE: str = "2141-001"
|
PAYABLE: str = "2141-001"
|
||||||
SALES: str = "4111-001"
|
SALES: str = "4111-001"
|
||||||
SERVICE: str = "4611-001"
|
SERVICE: str = "4611-001"
|
||||||
@ -48,7 +51,7 @@ class Accounts:
|
|||||||
OFFICE: str = "6153-001"
|
OFFICE: str = "6153-001"
|
||||||
TRAVEL: str = "6154-001"
|
TRAVEL: str = "6154-001"
|
||||||
MEAL: str = "6172-001"
|
MEAL: str = "6172-001"
|
||||||
INTEREST: str = "4111-001"
|
INTEREST: str = "7111-001"
|
||||||
DONATION: str = "7481-001"
|
DONATION: str = "7481-001"
|
||||||
RENT: str = "7482-001"
|
RENT: str = "7482-001"
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user