Compare commits
	
		
			27 Commits
		
	
	
		
			v1.1.0
			...
			1224d6f83e
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1224d6f83e | |||
| 3a8618f7c3 | |||
| 5d87205659 | |||
| 04de4f5c5e | |||
| f8ea863b80 | |||
| 5ae0d03b32 | |||
| a9a3ad5871 | |||
| 5edc95afce | |||
| 943ace6fc7 | |||
| a63bc977e9 | |||
| dabe6ddbca | |||
| f47e9b3150 | |||
| bb5383febe | |||
| 87f9063ceb | |||
| 51f0185bcf | |||
| 7ca08d6cc8 | |||
| c8e9e562be | |||
| ba43bd7e90 | |||
| 4e550413ba | |||
| 59a3cbb472 | |||
| d1b64d069e | |||
| d823d3254f | |||
| 5e9a2fb0c3 | |||
| 3f2e659ba5 | |||
| 9f7bb6b9de | |||
| 6857164702 | |||
| 6bac76be64 | 
@@ -53,7 +53,7 @@ def list_accounts() -> str:
 | 
			
		||||
                           list=pagination.list, pagination=pagination)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/create", endpoint="create")
 | 
			
		||||
@bp.get("create", endpoint="create")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def show_add_account_form() -> str:
 | 
			
		||||
    """Shows the form to add an account.
 | 
			
		||||
@@ -70,7 +70,7 @@ def show_add_account_form() -> str:
 | 
			
		||||
                           form=form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/store", endpoint="store")
 | 
			
		||||
@bp.post("store", endpoint="store")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def add_account() -> redirect:
 | 
			
		||||
    """Adds an account.
 | 
			
		||||
@@ -91,7 +91,7 @@ def add_account() -> redirect:
 | 
			
		||||
    return redirect(inherit_next(__get_detail_uri(account)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/<account:account>", endpoint="detail")
 | 
			
		||||
@bp.get("<account:account>", endpoint="detail")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def show_account_detail(account: Account) -> str:
 | 
			
		||||
    """Shows the account detail.
 | 
			
		||||
@@ -102,7 +102,7 @@ def show_account_detail(account: Account) -> str:
 | 
			
		||||
    return render_template("accounting/account/detail.html", obj=account)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/<account:account>/edit", endpoint="edit")
 | 
			
		||||
@bp.get("<account:account>/edit", endpoint="edit")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def show_account_edit_form(account: Account) -> str:
 | 
			
		||||
    """Shows the form to edit an account.
 | 
			
		||||
@@ -121,7 +121,7 @@ def show_account_edit_form(account: Account) -> str:
 | 
			
		||||
                           account=account, form=form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/<account:account>/update", endpoint="update")
 | 
			
		||||
@bp.post("<account:account>/update", endpoint="update")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def update_account(account: Account) -> redirect:
 | 
			
		||||
    """Updates an account.
 | 
			
		||||
@@ -148,7 +148,7 @@ def update_account(account: Account) -> redirect:
 | 
			
		||||
    return redirect(inherit_next(__get_detail_uri(account)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/<account:account>/delete", endpoint="delete")
 | 
			
		||||
@bp.post("<account:account>/delete", endpoint="delete")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def delete_account(account: Account) -> redirect:
 | 
			
		||||
    """Deletes an account.
 | 
			
		||||
@@ -167,7 +167,7 @@ def delete_account(account: Account) -> redirect:
 | 
			
		||||
    return redirect(or_next(__get_list_uri()))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/bases/<baseAccount:base>", endpoint="order")
 | 
			
		||||
@bp.get("bases/<baseAccount:base>", endpoint="order")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def show_account_order(base: BaseAccount) -> str:
 | 
			
		||||
    """Shows the order of the accounts under a same base account.
 | 
			
		||||
@@ -178,7 +178,7 @@ def show_account_order(base: BaseAccount) -> str:
 | 
			
		||||
    return render_template("accounting/account/order.html", base=base)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/bases/<baseAccount:base>", endpoint="sort")
 | 
			
		||||
@bp.post("bases/<baseAccount:base>", endpoint="sort")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def sort_accounts(base: BaseAccount) -> redirect:
 | 
			
		||||
    """Reorders the accounts under a base account.
 | 
			
		||||
 
 | 
			
		||||
@@ -41,7 +41,7 @@ def list_accounts() -> str:
 | 
			
		||||
                           list=pagination.list, pagination=pagination)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/<baseAccount:account>", endpoint="detail")
 | 
			
		||||
@bp.get("<baseAccount:account>", endpoint="detail")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def show_account_detail(account: BaseAccount) -> str:
 | 
			
		||||
    """Shows the account detail.
 | 
			
		||||
 
 | 
			
		||||
@@ -55,7 +55,7 @@ def list_currencies() -> str:
 | 
			
		||||
                           list=pagination.list, pagination=pagination)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/create", endpoint="create")
 | 
			
		||||
@bp.get("create", endpoint="create")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def show_add_currency_form() -> str:
 | 
			
		||||
    """Shows the form to add a currency.
 | 
			
		||||
@@ -72,7 +72,7 @@ def show_add_currency_form() -> str:
 | 
			
		||||
                           form=form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/store", endpoint="store")
 | 
			
		||||
@bp.post("store", endpoint="store")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def add_currency() -> redirect:
 | 
			
		||||
    """Adds a currency.
 | 
			
		||||
@@ -93,7 +93,7 @@ def add_currency() -> redirect:
 | 
			
		||||
    return redirect(inherit_next(__get_detail_uri(currency)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/<currency:currency>", endpoint="detail")
 | 
			
		||||
@bp.get("<currency:currency>", endpoint="detail")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def show_currency_detail(currency: Currency) -> str:
 | 
			
		||||
    """Shows the currency detail.
 | 
			
		||||
@@ -104,7 +104,7 @@ def show_currency_detail(currency: Currency) -> str:
 | 
			
		||||
    return render_template("accounting/currency/detail.html", obj=currency)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/<currency:currency>/edit", endpoint="edit")
 | 
			
		||||
@bp.get("<currency:currency>/edit", endpoint="edit")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def show_currency_edit_form(currency: Currency) -> str:
 | 
			
		||||
    """Shows the form to edit a currency.
 | 
			
		||||
@@ -123,7 +123,7 @@ def show_currency_edit_form(currency: Currency) -> str:
 | 
			
		||||
                           currency=currency, form=form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/<currency:currency>/update", endpoint="update")
 | 
			
		||||
@bp.post("<currency:currency>/update", endpoint="update")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def update_currency(currency: Currency) -> redirect:
 | 
			
		||||
    """Updates a currency.
 | 
			
		||||
@@ -151,7 +151,7 @@ def update_currency(currency: Currency) -> redirect:
 | 
			
		||||
    return redirect(inherit_next(__get_detail_uri(currency)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/<currency:currency>/delete", endpoint="delete")
 | 
			
		||||
@bp.post("<currency:currency>/delete", endpoint="delete")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def delete_currency(currency: Currency) -> redirect:
 | 
			
		||||
    """Deletes a currency.
 | 
			
		||||
@@ -169,7 +169,7 @@ def delete_currency(currency: Currency) -> redirect:
 | 
			
		||||
    return redirect(or_next(url_for("accounting.currency.list")))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@api_bp.get("/exists-code", endpoint="exists")
 | 
			
		||||
@api_bp.get("exists-code", endpoint="exists")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def exists_code() -> dict[str, bool]:
 | 
			
		||||
    """Validates whether a currency code exists.
 | 
			
		||||
 
 | 
			
		||||
@@ -49,7 +49,7 @@ bp.add_app_template_filter(format_amount_input,
 | 
			
		||||
bp.add_app_template_filter(text2html, "accounting_journal_entry_text2html")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/create/<journalEntryType:journal_entry_type>", endpoint="create")
 | 
			
		||||
@bp.get("create/<journalEntryType:journal_entry_type>", endpoint="create")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
 | 
			
		||||
    """Shows the form to add a journal entry.
 | 
			
		||||
@@ -71,7 +71,7 @@ def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
 | 
			
		||||
    return journal_entry_op.render_create_template(form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/store/<journalEntryType:journal_entry_type>", endpoint="store")
 | 
			
		||||
@bp.post("store/<journalEntryType:journal_entry_type>", endpoint="store")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
 | 
			
		||||
    """Adds a journal entry.
 | 
			
		||||
@@ -98,7 +98,7 @@ def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
 | 
			
		||||
    return redirect(inherit_next(__get_detail_uri(journal_entry)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/<journalEntry:journal_entry>", endpoint="detail")
 | 
			
		||||
@bp.get("<journalEntry:journal_entry>", endpoint="detail")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def show_journal_entry_detail(journal_entry: JournalEntry) -> str:
 | 
			
		||||
    """Shows the journal entry detail.
 | 
			
		||||
@@ -111,7 +111,7 @@ def show_journal_entry_detail(journal_entry: JournalEntry) -> str:
 | 
			
		||||
    return journal_entry_op.render_detail_template(journal_entry)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/<journalEntry:journal_entry>/edit", endpoint="edit")
 | 
			
		||||
@bp.get("<journalEntry:journal_entry>/edit", endpoint="edit")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
 | 
			
		||||
    """Shows the form to edit a journal entry.
 | 
			
		||||
@@ -133,7 +133,7 @@ def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
 | 
			
		||||
    return journal_entry_op.render_edit_template(journal_entry, form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/<journalEntry:journal_entry>/update", endpoint="update")
 | 
			
		||||
@bp.post("<journalEntry:journal_entry>/update", endpoint="update")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def update_journal_entry(journal_entry: JournalEntry) -> redirect:
 | 
			
		||||
    """Updates a journal entry.
 | 
			
		||||
@@ -166,7 +166,7 @@ def update_journal_entry(journal_entry: JournalEntry) -> redirect:
 | 
			
		||||
    return redirect(inherit_next(__get_detail_uri(journal_entry)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/<journalEntry:journal_entry>/delete", endpoint="delete")
 | 
			
		||||
@bp.post("<journalEntry:journal_entry>/delete", endpoint="delete")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
 | 
			
		||||
    """Deletes a journal entry.
 | 
			
		||||
@@ -186,7 +186,7 @@ def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
 | 
			
		||||
    return redirect(or_next(__get_default_page_uri()))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("/dates/<date:journal_entry_date>", endpoint="order")
 | 
			
		||||
@bp.get("dates/<date:journal_entry_date>", endpoint="order")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def show_journal_entry_order(journal_entry_date: date) -> str:
 | 
			
		||||
    """Shows the order of the journal entries in a same date.
 | 
			
		||||
@@ -201,7 +201,7 @@ def show_journal_entry_order(journal_entry_date: date) -> str:
 | 
			
		||||
                           date=journal_entry_date, list=journal_entries)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("/dates/<date:journal_entry_date>", endpoint="sort")
 | 
			
		||||
@bp.post("dates/<date:journal_entry_date>", endpoint="sort")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def sort_journal_entries(journal_entry_date: date) -> redirect:
 | 
			
		||||
    """Reorders the journal entries in a date.
 | 
			
		||||
 
 | 
			
		||||
@@ -22,6 +22,7 @@ from abc import ABC, abstractmethod
 | 
			
		||||
from datetime import timedelta, date
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
from io import StringIO
 | 
			
		||||
from urllib.parse import quote
 | 
			
		||||
 | 
			
		||||
from flask import Response
 | 
			
		||||
 | 
			
		||||
@@ -53,7 +54,7 @@ def csv_download(filename: str, rows: list[BaseCSVRow]) -> Response:
 | 
			
		||||
        fp.seek(0)
 | 
			
		||||
        response: Response = Response(fp.read(), mimetype="text/csv")
 | 
			
		||||
        response.headers["Content-Disposition"] \
 | 
			
		||||
            = f"attachment; filename={filename}"
 | 
			
		||||
            = f"attachment; filename={quote(filename)}"
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -47,9 +47,10 @@ def ledger_url(currency: Currency, account: Account, period: Period) \
 | 
			
		||||
    :param period: The period.
 | 
			
		||||
    :return: The URL of the ledger.
 | 
			
		||||
    """
 | 
			
		||||
    if period.is_default:
 | 
			
		||||
        return url_for("accounting-report.ledger-default",
 | 
			
		||||
                       currency=currency, account=account)
 | 
			
		||||
    if currency.code == default_currency_code() \
 | 
			
		||||
            and account.code == Account.CASH_CODE \
 | 
			
		||||
            and period.is_default:
 | 
			
		||||
        return url_for("accounting-report.ledger-default")
 | 
			
		||||
    return url_for("accounting-report.ledger",
 | 
			
		||||
                   currency=currency, account=account,
 | 
			
		||||
                   period=period)
 | 
			
		||||
@@ -68,9 +69,6 @@ def income_expenses_url(currency: Currency, account: CurrentAccount,
 | 
			
		||||
            and account.code == options.default_ie_account_code \
 | 
			
		||||
            and period.is_default:
 | 
			
		||||
        return url_for("accounting-report.default")
 | 
			
		||||
    if period.is_default:
 | 
			
		||||
        return url_for("accounting-report.income-expenses-default",
 | 
			
		||||
                       currency=currency, account=account)
 | 
			
		||||
    return url_for("accounting-report.income-expenses",
 | 
			
		||||
                   currency=currency, account=account,
 | 
			
		||||
                   period=period)
 | 
			
		||||
@@ -83,9 +81,8 @@ def trial_balance_url(currency: Currency, period: Period) -> str:
 | 
			
		||||
    :param period: The period.
 | 
			
		||||
    :return: The URL of the trial balance.
 | 
			
		||||
    """
 | 
			
		||||
    if period.is_default:
 | 
			
		||||
        return url_for("accounting-report.trial-balance-default",
 | 
			
		||||
                       currency=currency)
 | 
			
		||||
    if currency.code == default_currency_code() and period.is_default:
 | 
			
		||||
        return url_for("accounting-report.trial-balance-default")
 | 
			
		||||
    return url_for("accounting-report.trial-balance",
 | 
			
		||||
                   currency=currency, period=period)
 | 
			
		||||
 | 
			
		||||
@@ -97,9 +94,8 @@ def income_statement_url(currency: Currency, period: Period) -> str:
 | 
			
		||||
    :param period: The period.
 | 
			
		||||
    :return: The URL of the income statement.
 | 
			
		||||
    """
 | 
			
		||||
    if period.is_default:
 | 
			
		||||
        return url_for("accounting-report.income-statement-default",
 | 
			
		||||
                       currency=currency)
 | 
			
		||||
    if currency.code == default_currency_code() and period.is_default:
 | 
			
		||||
        return url_for("accounting-report.income-statement-default")
 | 
			
		||||
    return url_for("accounting-report.income-statement",
 | 
			
		||||
                   currency=currency, period=period)
 | 
			
		||||
 | 
			
		||||
@@ -111,9 +107,8 @@ def balance_sheet_url(currency: Currency, period: Period) -> str:
 | 
			
		||||
    :param period: The period.
 | 
			
		||||
    :return: The URL of the balance sheet.
 | 
			
		||||
    """
 | 
			
		||||
    if period.is_default:
 | 
			
		||||
        return url_for("accounting-report.balance-sheet-default",
 | 
			
		||||
                       currency=currency)
 | 
			
		||||
    if currency.code == default_currency_code() and period.is_default:
 | 
			
		||||
        return url_for("accounting-report.balance-sheet-default")
 | 
			
		||||
    return url_for("accounting-report.balance-sheet",
 | 
			
		||||
                   currency=currency, period=period)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -44,10 +44,7 @@ def get_default_report() -> str | Response:
 | 
			
		||||
 | 
			
		||||
    :return: The income and expenses log in the default period.
 | 
			
		||||
    """
 | 
			
		||||
    return __get_income_expenses(
 | 
			
		||||
        db.session.get(Currency, default_currency_code()),
 | 
			
		||||
        options.default_ie_account,
 | 
			
		||||
        get_period())
 | 
			
		||||
    return get_default_income_expenses()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("journal", endpoint="journal-default")
 | 
			
		||||
@@ -83,17 +80,15 @@ def __get_journal(period: Period) -> str | Response:
 | 
			
		||||
    return report.html()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("ledger/<currency:currency>/<account:account>",
 | 
			
		||||
        endpoint="ledger-default")
 | 
			
		||||
@bp.get("ledger", endpoint="ledger-default")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def get_default_ledger(currency: Currency, account: Account) -> str | Response:
 | 
			
		||||
    """Returns the ledger in the default period.
 | 
			
		||||
def get_default_ledger() -> str | Response:
 | 
			
		||||
    """Returns the ledger in the default currency, cash, and default period.
 | 
			
		||||
 | 
			
		||||
    :param currency: The currency.
 | 
			
		||||
    :param account: The account.
 | 
			
		||||
    :return: The ledger in the default period.
 | 
			
		||||
    :return: The ledger in the default currency, cash, and default period.
 | 
			
		||||
    """
 | 
			
		||||
    return __get_ledger(currency, account, get_period())
 | 
			
		||||
    return __get_ledger(db.session.get(Currency, default_currency_code()),
 | 
			
		||||
                        Account.cash(), get_period())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("ledger/<currency:currency>/<account:account>/<period:period>",
 | 
			
		||||
@@ -126,18 +121,17 @@ def __get_ledger(currency: Currency, account: Account, period: Period) \
 | 
			
		||||
    return report.html()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("income-expenses/<currency:currency>/<currentAccount:account>",
 | 
			
		||||
        endpoint="income-expenses-default")
 | 
			
		||||
@bp.get("income-expenses", endpoint="income-expenses-default")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def get_default_income_expenses(currency: Currency, account: CurrentAccount) \
 | 
			
		||||
        -> str | Response:
 | 
			
		||||
def get_default_income_expenses() -> str | Response:
 | 
			
		||||
    """Returns the income and expenses log in the default period.
 | 
			
		||||
 | 
			
		||||
    :param currency: The currency.
 | 
			
		||||
    :param account: The account.
 | 
			
		||||
    :return: The income and expenses log in the default period.
 | 
			
		||||
    """
 | 
			
		||||
    return __get_income_expenses(currency, account, get_period())
 | 
			
		||||
    return __get_income_expenses(
 | 
			
		||||
        db.session.get(Currency, default_currency_code()),
 | 
			
		||||
        options.default_ie_account,
 | 
			
		||||
        get_period())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("income-expenses/<currency:currency>/<currentAccount:account>/"
 | 
			
		||||
@@ -170,16 +164,15 @@ def __get_income_expenses(currency: Currency, account: CurrentAccount,
 | 
			
		||||
    return report.html()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("trial-balance/<currency:currency>",
 | 
			
		||||
        endpoint="trial-balance-default")
 | 
			
		||||
@bp.get("trial-balance", endpoint="trial-balance-default")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def get_default_trial_balance(currency: Currency) -> str | Response:
 | 
			
		||||
def get_default_trial_balance() -> str | Response:
 | 
			
		||||
    """Returns the trial balance in the default period.
 | 
			
		||||
 | 
			
		||||
    :param currency: The currency.
 | 
			
		||||
    :return: The trial balance in the default period.
 | 
			
		||||
    """
 | 
			
		||||
    return __get_trial_balance(currency, get_period())
 | 
			
		||||
    return __get_trial_balance(
 | 
			
		||||
        db.session.get(Currency, default_currency_code()), get_period())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("trial-balance/<currency:currency>/<period:period>",
 | 
			
		||||
@@ -208,16 +201,15 @@ def __get_trial_balance(currency: Currency, period: Period) -> str | Response:
 | 
			
		||||
    return report.html()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("income-statement/<currency:currency>",
 | 
			
		||||
        endpoint="income-statement-default")
 | 
			
		||||
@bp.get("income-statement", endpoint="income-statement-default")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def get_default_income_statement(currency: Currency) -> str | Response:
 | 
			
		||||
def get_default_income_statement() -> str | Response:
 | 
			
		||||
    """Returns the income statement in the default period.
 | 
			
		||||
 | 
			
		||||
    :param currency: The currency.
 | 
			
		||||
    :return: The income statement in the default period.
 | 
			
		||||
    """
 | 
			
		||||
    return __get_income_statement(currency, get_period())
 | 
			
		||||
    return __get_income_statement(
 | 
			
		||||
        db.session.get(Currency, default_currency_code()), get_period())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("income-statement/<currency:currency>/<period:period>",
 | 
			
		||||
@@ -247,16 +239,15 @@ def __get_income_statement(currency: Currency, period: Period) \
 | 
			
		||||
    return report.html()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("balance-sheet/<currency:currency>",
 | 
			
		||||
        endpoint="balance-sheet-default")
 | 
			
		||||
@bp.get("balance-sheet", endpoint="balance-sheet-default")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def get_default_balance_sheet(currency: Currency) -> str | Response:
 | 
			
		||||
def get_default_balance_sheet() -> str | Response:
 | 
			
		||||
    """Returns the balance sheet in the default period.
 | 
			
		||||
 | 
			
		||||
    :param currency: The currency.
 | 
			
		||||
    :return: The balance sheet in the default period.
 | 
			
		||||
    """
 | 
			
		||||
    return __get_balance_sheet(currency, get_period())
 | 
			
		||||
    return __get_balance_sheet(
 | 
			
		||||
        db.session.get(Currency, default_currency_code()), get_period())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("balance-sheet/<currency:currency>/<period:period>",
 | 
			
		||||
 
 | 
			
		||||
@@ -6,10 +6,10 @@
 | 
			
		||||
#
 | 
			
		||||
msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: mia-accounting 1.1.0\n"
 | 
			
		||||
"Project-Id-Version: mia-accounting 1.1.1\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
 | 
			
		||||
"POT-Creation-Date: 2023-04-09 00:22+0800\n"
 | 
			
		||||
"PO-Revision-Date: 2023-04-09 00:37+0800\n"
 | 
			
		||||
"POT-Creation-Date: 2023-04-09 01:41+0800\n"
 | 
			
		||||
"PO-Revision-Date: 2023-04-09 01:41+0800\n"
 | 
			
		||||
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
 | 
			
		||||
"Language: zh_Hant\n"
 | 
			
		||||
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
 | 
			
		||||
@@ -1250,7 +1250,7 @@ msgid "Trial Balance of %(currency)s %(period)s"
 | 
			
		||||
msgstr "%(period)s%(currency)s試算表"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/report/unapplied-accounts.html:24
 | 
			
		||||
#: src/accounting/templates/accounting/report/unapplied-accounts.html:41x
 | 
			
		||||
#: src/accounting/templates/accounting/report/unapplied-accounts.html:41
 | 
			
		||||
msgid "Accounts with Unapplied Original Line Items"
 | 
			
		||||
msgstr "有未抵銷原始分錄的科目"
 | 
			
		||||
 | 
			
		||||
@@ -1327,8 +1327,7 @@ msgstr "你確定要抵銷下列原始分錄與抵銷分錄嗎?結果無法復
 | 
			
		||||
msgid ""
 | 
			
		||||
"%(matches)s unapplied original line items out of %(total)s can match with"
 | 
			
		||||
" their offsets."
 | 
			
		||||
msgstr ""
 | 
			
		||||
"%(total)s 筆未抵銷原始分錄中,可配對抵銷掉 %(matches)s 筆。"
 | 
			
		||||
msgstr "%(total)s 筆未抵銷原始分錄中,可配對抵銷掉 %(matches)s 筆。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:83
 | 
			
		||||
#, python-format
 | 
			
		||||
@@ -1355,7 +1354,7 @@ msgstr "無法自動配對抵銷。"
 | 
			
		||||
#: src/accounting/unmatched_offset/views.py:77
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Matches %(matches)s from %(total)s unapplied line items."
 | 
			
		||||
msgstr "%(total)s 筆未抵銷原始分錄中,配對抵銷掉 %(matches) 筆。"
 | 
			
		||||
msgstr "%(total)s 筆未抵銷原始分錄中,配對抵銷掉 %(matches)s 筆。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/utils/current_account.py:65
 | 
			
		||||
msgid "current assets and liabilities"
 | 
			
		||||
 
 | 
			
		||||
@@ -27,8 +27,8 @@ from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import NEXT_URI, create_test_app, get_client, set_locale
 | 
			
		||||
from testlib_journal_entry import add_journal_entry
 | 
			
		||||
from testlib import NEXT_URI, create_test_app, get_client, set_locale, \
 | 
			
		||||
    add_journal_entry
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AccountData:
 | 
			
		||||
 
 | 
			
		||||
@@ -28,8 +28,8 @@ from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import NEXT_URI, create_test_app, get_client, set_locale
 | 
			
		||||
from testlib_journal_entry import add_journal_entry
 | 
			
		||||
from testlib import NEXT_URI, create_test_app, get_client, set_locale, \
 | 
			
		||||
    add_journal_entry
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CurrencyData:
 | 
			
		||||
 
 | 
			
		||||
@@ -24,8 +24,8 @@ from click.testing import Result
 | 
			
		||||
from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from testlib import NEXT_URI, Accounts, create_test_app, get_client
 | 
			
		||||
from testlib_journal_entry import add_journal_entry
 | 
			
		||||
from testlib import NEXT_URI, Accounts, create_test_app, get_client, \
 | 
			
		||||
    add_journal_entry
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DescriptionEditorTestCase(unittest.TestCase):
 | 
			
		||||
 
 | 
			
		||||
@@ -27,11 +27,12 @@ from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import NEXT_URI, Accounts, create_test_app, get_client
 | 
			
		||||
from testlib import NEXT_URI, Accounts, create_test_app, get_client, \
 | 
			
		||||
    add_journal_entry, match_journal_entry_detail
 | 
			
		||||
from testlib_journal_entry import NON_EMPTY_NOTE, EMPTY_NOTE, \
 | 
			
		||||
    get_add_form, get_unchanged_update_form, get_update_form, \
 | 
			
		||||
    match_journal_entry_detail, set_negative_amount, \
 | 
			
		||||
    remove_debit_in_a_currency, remove_credit_in_a_currency, add_journal_entry
 | 
			
		||||
    set_negative_amount, remove_debit_in_a_currency, \
 | 
			
		||||
    remove_credit_in_a_currency
 | 
			
		||||
 | 
			
		||||
PREFIX: str = "/accounting/journal-entries"
 | 
			
		||||
"""The URL prefix for the journal entry management."""
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,8 @@
 | 
			
		||||
"""The test for the offset.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
import unittest
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
@@ -26,10 +28,9 @@ from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import Accounts, create_test_app, get_client
 | 
			
		||||
from testlib_journal_entry import match_journal_entry_detail
 | 
			
		||||
from testlib_offset import TestData, JournalEntryLineItemData, \
 | 
			
		||||
    JournalEntryData, CurrencyData
 | 
			
		||||
from testlib import Accounts, create_test_app, get_client, \
 | 
			
		||||
    match_journal_entry_detail, JournalEntryLineItemData, \
 | 
			
		||||
    JournalEntryCurrencyData, JournalEntryData, BaseTestData
 | 
			
		||||
 | 
			
		||||
PREFIX: str = "/accounting/journal-entries"
 | 
			
		||||
"""The URL prefix for the journal entry management."""
 | 
			
		||||
@@ -66,7 +67,8 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
            JournalEntryLineItem.query.delete()
 | 
			
		||||
 | 
			
		||||
        self.client, self.csrf_token = get_client(self.app, "editor")
 | 
			
		||||
        self.data: TestData = TestData(self.app, self.client, self.csrf_token)
 | 
			
		||||
        self.data: OffsetTestData = OffsetTestData(
 | 
			
		||||
            self.app, self.client, self.csrf_token)
 | 
			
		||||
 | 
			
		||||
    def test_add_receivable_offset(self) -> None:
 | 
			
		||||
        """Tests to add the receivable offset.
 | 
			
		||||
@@ -81,7 +83,7 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        journal_entry_data: JournalEntryData = JournalEntryData(
 | 
			
		||||
            self.data.l_r_or3d.journal_entry.days, [CurrencyData(
 | 
			
		||||
            self.data.l_r_or3d.journal_entry.days, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD",
 | 
			
		||||
                [],
 | 
			
		||||
                [JournalEntryLineItemData(
 | 
			
		||||
@@ -107,7 +109,7 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        # The same debit or credit
 | 
			
		||||
        form = journal_entry_data.new_form(self.csrf_token)
 | 
			
		||||
        form["currency-1-credit-1-original_line_item_id"] \
 | 
			
		||||
            = self.data.l_p_or1c.id
 | 
			
		||||
            = str(self.data.l_p_or1c.id)
 | 
			
		||||
        form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account
 | 
			
		||||
        form["currency-1-credit-1-amount"] = "100"
 | 
			
		||||
        response = self.client.post(store_uri, data=form)
 | 
			
		||||
@@ -131,7 +133,7 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        # The original line item is also an offset
 | 
			
		||||
        form = journal_entry_data.new_form(self.csrf_token)
 | 
			
		||||
        form["currency-1-credit-1-original_line_item_id"] \
 | 
			
		||||
            = self.data.l_p_of1d.id
 | 
			
		||||
            = str(self.data.l_p_of1d.id)
 | 
			
		||||
        form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account
 | 
			
		||||
        response = self.client.post(store_uri, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
@@ -217,7 +219,7 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        # The same debit or credit
 | 
			
		||||
        form = journal_entry_data.update_form(self.csrf_token)
 | 
			
		||||
        form["currency-1-credit-1-original_line_item_id"] \
 | 
			
		||||
            = self.data.l_p_or1c.id
 | 
			
		||||
            = str(self.data.l_p_or1c.id)
 | 
			
		||||
        form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account
 | 
			
		||||
        form["currency-1-debit-1-amount"] = "100"
 | 
			
		||||
        form["currency-1-credit-1-amount"] = "100"
 | 
			
		||||
@@ -242,7 +244,7 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        # The original line item is also an offset
 | 
			
		||||
        form = journal_entry_data.update_form(self.csrf_token)
 | 
			
		||||
        form["currency-1-credit-1-original_line_item_id"] \
 | 
			
		||||
            = self.data.l_p_of1d.id
 | 
			
		||||
            = str(self.data.l_p_of1d.id)
 | 
			
		||||
        form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account
 | 
			
		||||
        response = self.client.post(update_uri, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
@@ -405,7 +407,7 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        journal_entry_data: JournalEntryData = JournalEntryData(
 | 
			
		||||
            self.data.l_p_or3c.journal_entry.days, [CurrencyData(
 | 
			
		||||
            self.data.l_p_or3c.journal_entry.days, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD",
 | 
			
		||||
                [JournalEntryLineItemData(
 | 
			
		||||
                    Accounts.PAYABLE,
 | 
			
		||||
@@ -431,7 +433,7 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        # The same debit or credit
 | 
			
		||||
        form = journal_entry_data.new_form(self.csrf_token)
 | 
			
		||||
        form["currency-1-debit-1-original_line_item_id"] \
 | 
			
		||||
            = self.data.l_r_or1d.id
 | 
			
		||||
            = str(self.data.l_r_or1d.id)
 | 
			
		||||
        form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account
 | 
			
		||||
        form["currency-1-debit-1-amount"] = "100"
 | 
			
		||||
        response = self.client.post(store_uri, data=form)
 | 
			
		||||
@@ -455,7 +457,7 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        # The original line item is also an offset
 | 
			
		||||
        form = journal_entry_data.new_form(self.csrf_token)
 | 
			
		||||
        form["currency-1-debit-1-original_line_item_id"] \
 | 
			
		||||
            = self.data.l_r_of1c.id
 | 
			
		||||
            = str(self.data.l_r_of1c.id)
 | 
			
		||||
        form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account
 | 
			
		||||
        response = self.client.post(store_uri, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
@@ -541,7 +543,7 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        # The same debit or credit
 | 
			
		||||
        form = journal_entry_data.update_form(self.csrf_token)
 | 
			
		||||
        form["currency-1-debit-1-original_line_item_id"] \
 | 
			
		||||
            = self.data.l_r_or1d.id
 | 
			
		||||
            = str(self.data.l_r_or1d.id)
 | 
			
		||||
        form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account
 | 
			
		||||
        form["currency-1-debit-1-amount"] = "100"
 | 
			
		||||
        form["currency-1-credit-1-amount"] = "100"
 | 
			
		||||
@@ -566,7 +568,7 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
        # The original line item is also an offset
 | 
			
		||||
        form = journal_entry_data.update_form(self.csrf_token)
 | 
			
		||||
        form["currency-1-debit-1-original_line_item_id"] \
 | 
			
		||||
            = self.data.l_r_of1c.id
 | 
			
		||||
            = str(self.data.l_r_of1c.id)
 | 
			
		||||
        form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account
 | 
			
		||||
        response = self.client.post(update_uri, data=form)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
@@ -720,3 +722,114 @@ class OffsetTestCase(unittest.TestCase):
 | 
			
		||||
            self.assertIsNotNone(journal_entry_of)
 | 
			
		||||
            self.assertEqual(journal_entry_or.date, journal_entry_of.date)
 | 
			
		||||
            self.assertLess(journal_entry_or.no, journal_entry_of.no)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OffsetTestData(BaseTestData):
 | 
			
		||||
    """The offset test data."""
 | 
			
		||||
 | 
			
		||||
    def _init_data(self) -> None:
 | 
			
		||||
        # Receivable original line items
 | 
			
		||||
        self.l_r_or1d, self.l_r_or1c = self._couple(
 | 
			
		||||
            "Accountant", "1200", Accounts.RECEIVABLE, Accounts.SERVICE)
 | 
			
		||||
        self.l_r_or2d, self.l_r_or2c = self._couple(
 | 
			
		||||
            "Toy", "600", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or3d, self.l_r_or3c = self._couple(
 | 
			
		||||
            "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or4d, self.l_r_or4c = self._couple(
 | 
			
		||||
            "Interest", "3.4", Accounts.RECEIVABLE, Accounts.INTEREST)
 | 
			
		||||
 | 
			
		||||
        # Payable original line items
 | 
			
		||||
        self.l_p_or1d, self.l_p_or1c = self._couple(
 | 
			
		||||
            "Airplane", "2000", Accounts.TRAVEL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or2d, self.l_p_or2c = self._couple(
 | 
			
		||||
            "Phone", "900", Accounts.OFFICE, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or3d, self.l_p_or3c = self._couple(
 | 
			
		||||
            "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or4d, self.l_p_or4c = self._couple(
 | 
			
		||||
            "Envelop", "0.9", Accounts.OFFICE, Accounts.PAYABLE)
 | 
			
		||||
 | 
			
		||||
        # Original journal entries
 | 
			
		||||
        self.j_r_or1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            50, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_or1d, self.l_r_or4d],
 | 
			
		||||
                [self.l_r_or1c, self.l_r_or4c])])
 | 
			
		||||
        self.j_r_or2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            30, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_or2d, self.l_r_or3d],
 | 
			
		||||
                [self.l_r_or2c, self.l_r_or3c])])
 | 
			
		||||
        self.j_p_or1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            40, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_or1d, self.l_p_or4d],
 | 
			
		||||
                [self.l_p_or1c, self.l_p_or4c])])
 | 
			
		||||
        self.j_p_or2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            20, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_or2d, self.l_p_or3d],
 | 
			
		||||
                [self.l_p_or2c, self.l_p_or3c])])
 | 
			
		||||
 | 
			
		||||
        self._add_journal_entry(self.j_r_or1)
 | 
			
		||||
        self._add_journal_entry(self.j_r_or2)
 | 
			
		||||
        self._add_journal_entry(self.j_p_or1)
 | 
			
		||||
        self._add_journal_entry(self.j_p_or2)
 | 
			
		||||
 | 
			
		||||
        # Receivable offset items
 | 
			
		||||
        self.l_r_of1d, self.l_r_of1c = self._couple(
 | 
			
		||||
            "Accountant", "500", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of1c.original_line_item = self.l_r_or1d
 | 
			
		||||
        self.l_r_of2d, self.l_r_of2c = self._couple(
 | 
			
		||||
            "Accountant", "200", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of2c.original_line_item = self.l_r_or1d
 | 
			
		||||
        self.l_r_of3d, self.l_r_of3c = self._couple(
 | 
			
		||||
            "Accountant", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of3c.original_line_item = self.l_r_or1d
 | 
			
		||||
        self.l_r_of4d, self.l_r_of4c = self._couple(
 | 
			
		||||
            "Toy", "240", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of4c.original_line_item = self.l_r_or2d
 | 
			
		||||
        self.l_r_of5d, self.l_r_of5c = self._couple(
 | 
			
		||||
            "Interest", "3.4", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of5c.original_line_item = self.l_r_or4d
 | 
			
		||||
 | 
			
		||||
        # Payable offset items
 | 
			
		||||
        self.l_p_of1d, self.l_p_of1c = self._couple(
 | 
			
		||||
            "Airplane", "800", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of1d.original_line_item = self.l_p_or1c
 | 
			
		||||
        self.l_p_of2d, self.l_p_of2c = self._couple(
 | 
			
		||||
            "Airplane", "300", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of2d.original_line_item = self.l_p_or1c
 | 
			
		||||
        self.l_p_of3d, self.l_p_of3c = self._couple(
 | 
			
		||||
            "Airplane", "100", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of3d.original_line_item = self.l_p_or1c
 | 
			
		||||
        self.l_p_of4d, self.l_p_of4c = self._couple(
 | 
			
		||||
            "Phone", "400", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of4d.original_line_item = self.l_p_or2c
 | 
			
		||||
        self.l_p_of5d, self.l_p_of5c = self._couple(
 | 
			
		||||
            "Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of5d.original_line_item = self.l_p_or4c
 | 
			
		||||
 | 
			
		||||
        # Offset journal entries
 | 
			
		||||
        self.j_r_of1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            25, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_of1d], [self.l_r_of1c])])
 | 
			
		||||
        self.j_r_of2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            20, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_of2d, self.l_r_of3d, self.l_r_of4d],
 | 
			
		||||
                [self.l_r_of2c, self.l_r_of3c, self.l_r_of4c])])
 | 
			
		||||
        self.j_r_of3: JournalEntryData = JournalEntryData(
 | 
			
		||||
            15, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_of5d], [self.l_r_of5c])])
 | 
			
		||||
        self.j_p_of1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            15, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_of1d], [self.l_p_of1c])])
 | 
			
		||||
        self.j_p_of2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            10, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_of2d, self.l_p_of3d, self.l_p_of4d],
 | 
			
		||||
                [self.l_p_of2c, self.l_p_of3c, self.l_p_of4c])])
 | 
			
		||||
        self.j_p_of3: JournalEntryData = JournalEntryData(
 | 
			
		||||
            5, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_of5d], [self.l_p_of5c])])
 | 
			
		||||
 | 
			
		||||
        self._add_journal_entry(self.j_r_of1)
 | 
			
		||||
        self._add_journal_entry(self.j_r_of2)
 | 
			
		||||
        self._add_journal_entry(self.j_r_of3)
 | 
			
		||||
        self._add_journal_entry(self.j_p_of1)
 | 
			
		||||
        self._add_journal_entry(self.j_p_of2)
 | 
			
		||||
        self._add_journal_entry(self.j_p_of3)
 | 
			
		||||
 
 | 
			
		||||
@@ -27,7 +27,6 @@ from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import NEXT_URI, Accounts, create_test_app, get_client
 | 
			
		||||
from testlib_offset import TestData
 | 
			
		||||
 | 
			
		||||
PREFIX: str = "/accounting/options"
 | 
			
		||||
"""The URL prefix for the option management."""
 | 
			
		||||
@@ -68,7 +67,6 @@ class OptionTestCase(unittest.TestCase):
 | 
			
		||||
            Option.query.delete()
 | 
			
		||||
 | 
			
		||||
        self.client, self.csrf_token = get_client(self.app, "admin")
 | 
			
		||||
        self.data: TestData = TestData(self.app, self.client, self.csrf_token)
 | 
			
		||||
 | 
			
		||||
    def test_nobody(self) -> None:
 | 
			
		||||
        """Test the permission as nobody.
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										415
									
								
								tests/test_report.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										415
									
								
								tests/test_report.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,415 @@
 | 
			
		||||
# The Mia! Accounting Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/9
 | 
			
		||||
 | 
			
		||||
#  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 reports.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import unittest
 | 
			
		||||
from datetime import date
 | 
			
		||||
 | 
			
		||||
import httpx
 | 
			
		||||
from click.testing import Result
 | 
			
		||||
from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from testlib import create_test_app, get_client, Accounts, BaseTestData
 | 
			
		||||
 | 
			
		||||
PREFIX: str = "/accounting"
 | 
			
		||||
"""The URL prefix for the reports."""
 | 
			
		||||
CSV_MIME: str = "text/csv; charset=utf-8"
 | 
			
		||||
"""The MIME type of the downloaded CSV files."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ReportTestCase(unittest.TestCase):
 | 
			
		||||
    """The report 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, JournalEntry, \
 | 
			
		||||
                JournalEntryLineItem
 | 
			
		||||
            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)
 | 
			
		||||
            JournalEntry.query.delete()
 | 
			
		||||
            JournalEntryLineItem.query.delete()
 | 
			
		||||
 | 
			
		||||
        self.client, self.csrf_token = get_client(self.app, "editor")
 | 
			
		||||
 | 
			
		||||
    def test_nobody(self) -> None:
 | 
			
		||||
        """Test the permission as nobody.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        client, csrf_token = get_client(self.app, "nobody")
 | 
			
		||||
        ReportTestData(self.app, self.client, self.csrf_token)
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/journal")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/journal?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/ledger")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/ledger?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/income-expenses")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/income-expenses?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/trial-balance")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/trial-balance?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/income-statement")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/income-statement?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/balance-sheet")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/balance-sheet?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/unapplied")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/unapplied?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/search?q=Salary")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/search?q=Salary&as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/search?q=薪水")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/search?q=薪水&as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
    def test_viewer(self) -> None:
 | 
			
		||||
        """Test the permission as viewer.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        client, csrf_token = get_client(self.app, "viewer")
 | 
			
		||||
        ReportTestData(self.app, self.client, self.csrf_token)
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/journal")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/journal?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/ledger")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/ledger?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/income-expenses")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/income-expenses?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/trial-balance")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/trial-balance?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/income-statement")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/income-statement?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/balance-sheet")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/balance-sheet?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/unapplied")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/unapplied?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/search?q=Salary")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/search?q=Salary&as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/search?q=薪水")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/search?q=薪水&as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
    def test_editor(self) -> None:
 | 
			
		||||
        """Test the permission as editor.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        ReportTestData(self.app, self.client, self.csrf_token)
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/journal")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/journal?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/ledger")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/ledger?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/income-expenses")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/income-expenses?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/trial-balance")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/trial-balance?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/income-statement")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/income-statement?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/balance-sheet")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/balance-sheet?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/unapplied")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/unapplied?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/search?q=Salary")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/search?q=Salary&as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/search?q=薪水")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/search?q=薪水&as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
    def test_empty_db(self) -> None:
 | 
			
		||||
        """Tests the empty database.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/journal")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/journal?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/ledger")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/ledger?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/income-expenses")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/income-expenses?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/trial-balance")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/trial-balance?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/income-statement")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/income-statement?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/balance-sheet")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/balance-sheet?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/unapplied")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/unapplied?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/search?q=Salary")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/search?q=Salary&as=csv")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertEqual(response.headers["Content-Type"], CSV_MIME)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ReportTestData(BaseTestData):
 | 
			
		||||
    """The report test data."""
 | 
			
		||||
 | 
			
		||||
    def _init_data(self) -> None:
 | 
			
		||||
        today: date = date.today()
 | 
			
		||||
        year: int = today.year - 5
 | 
			
		||||
        month: int = today.month
 | 
			
		||||
        while True:
 | 
			
		||||
            j_date: date = date(year, month, 5)
 | 
			
		||||
            if j_date > today:
 | 
			
		||||
                break
 | 
			
		||||
            self._add_simple_journal_entry(
 | 
			
		||||
                (j_date - today).days, "USD",
 | 
			
		||||
                "Salary薪水", "1200", Accounts.BANK, Accounts.SERVICE)
 | 
			
		||||
            month = month + 1
 | 
			
		||||
            if month > 12:
 | 
			
		||||
                year = year + 1
 | 
			
		||||
                month = 1
 | 
			
		||||
        self._add_simple_journal_entry(
 | 
			
		||||
            1, "USD", "Withdraw領錢", "1000", Accounts.CASH, Accounts.BANK)
 | 
			
		||||
        self._add_simple_journal_entry(
 | 
			
		||||
            0, "USD", "Dinner晚餐", "40", Accounts.MEAL, Accounts.CASH)
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
# The Mia! Accounting Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/11
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#
 | 
			
		||||
@@ -14,7 +14,7 @@
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The test for the offset matcher.
 | 
			
		||||
"""The test for the unmatched offsets.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import unittest
 | 
			
		||||
@@ -25,14 +25,15 @@ from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import create_test_app, get_client, Accounts
 | 
			
		||||
from testlib_journal_entry import match_journal_entry_detail
 | 
			
		||||
from testlib_offset import JournalEntryData, CurrencyData, \
 | 
			
		||||
    JournalEntryLineItemData
 | 
			
		||||
from testlib import create_test_app, get_client, Accounts, \
 | 
			
		||||
    JournalEntryCurrencyData, JournalEntryData, BaseTestData
 | 
			
		||||
 | 
			
		||||
PREFIX: str = "/accounting/unmatched-offsets"
 | 
			
		||||
"""The URL prefix for the unmatched offset management."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OffsetMatcherTestCase(unittest.TestCase):
 | 
			
		||||
    """The offset matcher test case."""
 | 
			
		||||
class UnmatchedOffsetTestCase(unittest.TestCase):
 | 
			
		||||
    """The unmatched offset test case."""
 | 
			
		||||
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
        """Sets up the test.
 | 
			
		||||
@@ -63,6 +64,83 @@ class OffsetMatcherTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        self.client, self.csrf_token = get_client(self.app, "editor")
 | 
			
		||||
 | 
			
		||||
    def test_nobody(self) -> None:
 | 
			
		||||
        """Test the permission as nobody.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        client, csrf_token = get_client(self.app, "nobody")
 | 
			
		||||
        DifferentTestData(self.app, self.client, self.csrf_token)
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/{Accounts.PAYABLE}")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.post(f"{PREFIX}/{Accounts.PAYABLE}",
 | 
			
		||||
                               data={"csrf_token": csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
    def test_viewer(self) -> None:
 | 
			
		||||
        """Test the permission as viewer.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        client, csrf_token = get_client(self.app, "viewer")
 | 
			
		||||
        DifferentTestData(self.app, self.client, self.csrf_token)
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.get(f"{PREFIX}/{Accounts.PAYABLE}")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = client.post(f"{PREFIX}/{Accounts.PAYABLE}",
 | 
			
		||||
                               data={"csrf_token": csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
    def test_editor(self) -> None:
 | 
			
		||||
        """Test the permission as editor.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        DifferentTestData(self.app, self.client, self.csrf_token)
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/{Accounts.PAYABLE}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/{Accounts.PAYABLE}",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         f"{PREFIX}/{Accounts.PAYABLE}")
 | 
			
		||||
 | 
			
		||||
    def test_empty_db(self) -> None:
 | 
			
		||||
        """Test the empty database.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/{Accounts.PAYABLE}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/{Accounts.PAYABLE}",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         f"{PREFIX}/{Accounts.PAYABLE}")
 | 
			
		||||
 | 
			
		||||
    def test_different(self) -> None:
 | 
			
		||||
        """Tests to match against different descriptions and amounts.
 | 
			
		||||
 | 
			
		||||
@@ -76,6 +154,7 @@ class OffsetMatcherTestCase(unittest.TestCase):
 | 
			
		||||
        line_item: JournalEntryLineItem | None
 | 
			
		||||
        matcher: OffsetMatcher
 | 
			
		||||
        list_uri: str
 | 
			
		||||
        match_uri: str
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        # The receivables
 | 
			
		||||
@@ -100,8 +179,9 @@ class OffsetMatcherTestCase(unittest.TestCase):
 | 
			
		||||
                self.assertIsNotNone(line_item)
 | 
			
		||||
                self.assertIsNone(line_item.original_line_item_id)
 | 
			
		||||
 | 
			
		||||
        list_uri = f"/accounting/unmatched-offsets/{Accounts.RECEIVABLE}"
 | 
			
		||||
        response = self.client.post(list_uri,
 | 
			
		||||
        list_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
 | 
			
		||||
        match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
 | 
			
		||||
        response = self.client.post(match_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], list_uri)
 | 
			
		||||
@@ -149,8 +229,9 @@ class OffsetMatcherTestCase(unittest.TestCase):
 | 
			
		||||
                self.assertIsNotNone(line_item)
 | 
			
		||||
                self.assertIsNone(line_item.original_line_item_id)
 | 
			
		||||
 | 
			
		||||
        list_uri = f"/accounting/unmatched-offsets/{Accounts.PAYABLE}"
 | 
			
		||||
        response = self.client.post(list_uri,
 | 
			
		||||
        list_uri = f"{PREFIX}/{Accounts.PAYABLE}"
 | 
			
		||||
        match_uri = f"{PREFIX}/{Accounts.PAYABLE}"
 | 
			
		||||
        response = self.client.post(match_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], list_uri)
 | 
			
		||||
@@ -189,6 +270,7 @@ class OffsetMatcherTestCase(unittest.TestCase):
 | 
			
		||||
        line_item: JournalEntryLineItem | None
 | 
			
		||||
        matcher: OffsetMatcher
 | 
			
		||||
        list_uri: str
 | 
			
		||||
        match_uri: str
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        # The receivables
 | 
			
		||||
@@ -220,8 +302,9 @@ class OffsetMatcherTestCase(unittest.TestCase):
 | 
			
		||||
            self.assertIsNotNone(line_item.original_line_item_id)
 | 
			
		||||
            self.assertEqual(line_item.original_line_item_id, data.l_r_or2d.id)
 | 
			
		||||
 | 
			
		||||
        list_uri = f"/accounting/unmatched-offsets/{Accounts.RECEIVABLE}"
 | 
			
		||||
        response = self.client.post(list_uri,
 | 
			
		||||
        list_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
 | 
			
		||||
        match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
 | 
			
		||||
        response = self.client.post(match_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], list_uri)
 | 
			
		||||
@@ -285,8 +368,9 @@ class OffsetMatcherTestCase(unittest.TestCase):
 | 
			
		||||
            self.assertIsNotNone(line_item.original_line_item_id)
 | 
			
		||||
            self.assertEqual(line_item.original_line_item_id, data.l_p_or2c.id)
 | 
			
		||||
 | 
			
		||||
        list_uri = f"/accounting/unmatched-offsets/{Accounts.PAYABLE}"
 | 
			
		||||
        response = self.client.post(list_uri,
 | 
			
		||||
        list_uri = f"{PREFIX}/{Accounts.PAYABLE}"
 | 
			
		||||
        match_uri = f"{PREFIX}/{Accounts.PAYABLE}"
 | 
			
		||||
        response = self.client.post(match_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], list_uri)
 | 
			
		||||
@@ -322,371 +406,179 @@ class OffsetMatcherTestCase(unittest.TestCase):
 | 
			
		||||
            self.assertEqual(line_item.original_line_item_id, data.l_p_or4c.id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DifferentTestData:
 | 
			
		||||
class DifferentTestData(BaseTestData):
 | 
			
		||||
    """The test data for different descriptions and amounts."""
 | 
			
		||||
 | 
			
		||||
    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(description: str, amount: str, debit: str, credit: str) \
 | 
			
		||||
                -> tuple[JournalEntryLineItemData, JournalEntryLineItemData]:
 | 
			
		||||
            """Returns a couple of debit-credit line items.
 | 
			
		||||
 | 
			
		||||
            :param description: The description.
 | 
			
		||||
            :param amount: The amount.
 | 
			
		||||
            :param debit: The debit account code.
 | 
			
		||||
            :param credit: The credit account code.
 | 
			
		||||
            :return: The debit line item and credit line item.
 | 
			
		||||
            """
 | 
			
		||||
            return JournalEntryLineItemData(debit, description, amount),\
 | 
			
		||||
                JournalEntryLineItemData(credit, description, amount)
 | 
			
		||||
 | 
			
		||||
    def _init_data(self) -> None:
 | 
			
		||||
        # Receivable original line items
 | 
			
		||||
        self.l_r_or1d, self.l_r_or1c = couple(
 | 
			
		||||
        self.l_r_or1d, self.l_r_or1c = self._couple(
 | 
			
		||||
            "Accountant", "1200", Accounts.RECEIVABLE, Accounts.SERVICE)
 | 
			
		||||
        self.l_r_or2d, self.l_r_or2c = couple(
 | 
			
		||||
        self.l_r_or2d, self.l_r_or2c = self._couple(
 | 
			
		||||
            "Toy", "600", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or3d, self.l_r_or3c = couple(
 | 
			
		||||
        self.l_r_or3d, self.l_r_or3c = self._couple(
 | 
			
		||||
            "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or4d, self.l_r_or4c = couple(
 | 
			
		||||
        self.l_r_or4d, self.l_r_or4c = self._couple(
 | 
			
		||||
            "Interest", "3.4", Accounts.RECEIVABLE, Accounts.INTEREST)
 | 
			
		||||
 | 
			
		||||
        # Payable original line items
 | 
			
		||||
        self.l_p_or1d, self.l_p_or1c = couple(
 | 
			
		||||
        self.l_p_or1d, self.l_p_or1c = self._couple(
 | 
			
		||||
            "Airplane", "2000", Accounts.TRAVEL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or2d, self.l_p_or2c = couple(
 | 
			
		||||
        self.l_p_or2d, self.l_p_or2c = self._couple(
 | 
			
		||||
            "Phone", "900", Accounts.OFFICE, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or3d, self.l_p_or3c = couple(
 | 
			
		||||
        self.l_p_or3d, self.l_p_or3c = self._couple(
 | 
			
		||||
            "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or4d, self.l_p_or4c = couple(
 | 
			
		||||
        self.l_p_or4d, self.l_p_or4c = self._couple(
 | 
			
		||||
            "Envelop", "0.9", Accounts.OFFICE, Accounts.PAYABLE)
 | 
			
		||||
 | 
			
		||||
        # Original journal entries
 | 
			
		||||
        self.j_r_or1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            50, [CurrencyData("USD", [self.l_r_or1d, self.l_r_or4d],
 | 
			
		||||
                              [self.l_r_or1c, self.l_r_or4c])])
 | 
			
		||||
            50, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_or1d, self.l_r_or4d],
 | 
			
		||||
                [self.l_r_or1c, self.l_r_or4c])])
 | 
			
		||||
        self.j_r_or2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            30, [CurrencyData("USD", [self.l_r_or2d, self.l_r_or3d],
 | 
			
		||||
                              [self.l_r_or2c, self.l_r_or3c])])
 | 
			
		||||
            30, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_or2d, self.l_r_or3d],
 | 
			
		||||
                [self.l_r_or2c, self.l_r_or3c])])
 | 
			
		||||
        self.j_p_or1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            40, [CurrencyData("USD", [self.l_p_or1d, self.l_p_or4d],
 | 
			
		||||
                              [self.l_p_or1c, self.l_p_or4c])])
 | 
			
		||||
            40, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_or1d, self.l_p_or4d],
 | 
			
		||||
                [self.l_p_or1c, self.l_p_or4c])])
 | 
			
		||||
        self.j_p_or2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            20, [CurrencyData("USD", [self.l_p_or2d, self.l_p_or3d],
 | 
			
		||||
                              [self.l_p_or2c, self.l_p_or3c])])
 | 
			
		||||
            20, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_or2d, self.l_p_or3d],
 | 
			
		||||
                [self.l_p_or2c, self.l_p_or3c])])
 | 
			
		||||
 | 
			
		||||
        self.__add_journal_entry(self.j_r_or1)
 | 
			
		||||
        self.__add_journal_entry(self.j_r_or2)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_or1)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_or2)
 | 
			
		||||
        self._add_journal_entry(self.j_r_or1)
 | 
			
		||||
        self._add_journal_entry(self.j_r_or2)
 | 
			
		||||
        self._add_journal_entry(self.j_p_or1)
 | 
			
		||||
        self._add_journal_entry(self.j_p_or2)
 | 
			
		||||
 | 
			
		||||
        # Receivable offset items
 | 
			
		||||
        self.l_r_of1d, self.l_r_of1c = couple(
 | 
			
		||||
        self.l_r_of1d, self.l_r_of1c = self._couple(
 | 
			
		||||
            "Accountant", "500", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of2d, self.l_r_of2c = couple(
 | 
			
		||||
        self.l_r_of2d, self.l_r_of2c = self._couple(
 | 
			
		||||
            "Accountant", "200", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of3d, self.l_r_of3c = couple(
 | 
			
		||||
        self.l_r_of3d, self.l_r_of3c = self._couple(
 | 
			
		||||
            "Accountant", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of4d, self.l_r_of4c = couple(
 | 
			
		||||
        self.l_r_of4d, self.l_r_of4c = self._couple(
 | 
			
		||||
            "Toy", "240", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of5d, self.l_r_of5c = couple(
 | 
			
		||||
        self.l_r_of5d, self.l_r_of5c = self._couple(
 | 
			
		||||
            "Interest", "3.4", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
 | 
			
		||||
        # Payable offset items
 | 
			
		||||
        self.l_p_of1d, self.l_p_of1c = couple(
 | 
			
		||||
        self.l_p_of1d, self.l_p_of1c = self._couple(
 | 
			
		||||
            "Airplane", "800", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of2d, self.l_p_of2c = couple(
 | 
			
		||||
        self.l_p_of2d, self.l_p_of2c = self._couple(
 | 
			
		||||
            "Airplane", "300", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of3d, self.l_p_of3c = couple(
 | 
			
		||||
        self.l_p_of3d, self.l_p_of3c = self._couple(
 | 
			
		||||
            "Airplane", "100", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of4d, self.l_p_of4c = couple(
 | 
			
		||||
        self.l_p_of4d, self.l_p_of4c = self._couple(
 | 
			
		||||
            "Phone", "400", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of5d, self.l_p_of5c = couple(
 | 
			
		||||
        self.l_p_of5d, self.l_p_of5c = self._couple(
 | 
			
		||||
            "Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
 | 
			
		||||
        # Offset journal entries
 | 
			
		||||
        self.j_r_of1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            25, [CurrencyData("USD", [self.l_r_of1d], [self.l_r_of1c])])
 | 
			
		||||
            25, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_of1d], [self.l_r_of1c])])
 | 
			
		||||
        self.j_r_of2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            20, [CurrencyData("USD",
 | 
			
		||||
                              [self.l_r_of2d, self.l_r_of3d, self.l_r_of4d],
 | 
			
		||||
                              [self.l_r_of2c, self.l_r_of3c, self.l_r_of4c])])
 | 
			
		||||
            20, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_of2d, self.l_r_of3d, self.l_r_of4d],
 | 
			
		||||
                [self.l_r_of2c, self.l_r_of3c, self.l_r_of4c])])
 | 
			
		||||
        self.j_r_of3: JournalEntryData = JournalEntryData(
 | 
			
		||||
            15, [CurrencyData("USD", [self.l_r_of5d], [self.l_r_of5c])])
 | 
			
		||||
            15, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_of5d], [self.l_r_of5c])])
 | 
			
		||||
        self.j_p_of1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            15, [CurrencyData("USD", [self.l_p_of1d], [self.l_p_of1c])])
 | 
			
		||||
            15, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_of1d], [self.l_p_of1c])])
 | 
			
		||||
        self.j_p_of2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            10, [CurrencyData("USD",
 | 
			
		||||
                              [self.l_p_of2d, self.l_p_of3d, self.l_p_of4d],
 | 
			
		||||
                              [self.l_p_of2c, self.l_p_of3c, self.l_p_of4c])])
 | 
			
		||||
            10, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_of2d, self.l_p_of3d, self.l_p_of4d],
 | 
			
		||||
                [self.l_p_of2c, self.l_p_of3c, self.l_p_of4c])])
 | 
			
		||||
        self.j_p_of3: JournalEntryData = JournalEntryData(
 | 
			
		||||
            5, [CurrencyData("USD", [self.l_p_of5d], [self.l_p_of5c])])
 | 
			
		||||
            5, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_of5d], [self.l_p_of5c])])
 | 
			
		||||
 | 
			
		||||
        self.__set_is_need_offset(False)
 | 
			
		||||
        self.__add_journal_entry(self.j_r_of1)
 | 
			
		||||
        self.__add_journal_entry(self.j_r_of2)
 | 
			
		||||
        self.__add_journal_entry(self.j_r_of3)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_of1)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_of2)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_of3)
 | 
			
		||||
        self.__set_is_need_offset(True)
 | 
			
		||||
 | 
			
		||||
    def __set_is_need_offset(self, is_need_offset: bool) -> None:
 | 
			
		||||
        """Sets whether the payables and receivables need offset.
 | 
			
		||||
 | 
			
		||||
        :param is_need_offset: True if payables and receivables need offset, or
 | 
			
		||||
            False otherwise.
 | 
			
		||||
        :return:
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Account
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            for code in {Accounts.RECEIVABLE, Accounts.PAYABLE}:
 | 
			
		||||
                account: Account | None = Account.find_by_code(code)
 | 
			
		||||
                assert account is not None
 | 
			
		||||
                account.is_need_offset = is_need_offset
 | 
			
		||||
            db.session.commit()
 | 
			
		||||
 | 
			
		||||
    def __add_journal_entry(self, journal_entry_data: JournalEntryData) \
 | 
			
		||||
            -> None:
 | 
			
		||||
        """Adds a journal entry.
 | 
			
		||||
 | 
			
		||||
        :param journal_entry_data: The journal entry data.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import JournalEntry
 | 
			
		||||
        store_uri: str = "/accounting/journal-entries/store/transfer"
 | 
			
		||||
 | 
			
		||||
        response: httpx.Response = self.client.post(
 | 
			
		||||
            store_uri, data=journal_entry_data.new_form(self.csrf_token))
 | 
			
		||||
        assert response.status_code == 302
 | 
			
		||||
        journal_entry_id: int \
 | 
			
		||||
            = match_journal_entry_detail(response.headers["Location"])
 | 
			
		||||
        journal_entry_data.id = journal_entry_id
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            journal_entry: JournalEntry | None \
 | 
			
		||||
                = db.session.get(JournalEntry, journal_entry_id)
 | 
			
		||||
            assert journal_entry is not None
 | 
			
		||||
            for i in range(len(journal_entry.currencies)):
 | 
			
		||||
                for j in range(len(journal_entry.currencies[i].debit)):
 | 
			
		||||
                    journal_entry_data.currencies[i].debit[j].id \
 | 
			
		||||
                        = journal_entry.currencies[i].debit[j].id
 | 
			
		||||
                for j in range(len(journal_entry.currencies[i].credit)):
 | 
			
		||||
                    journal_entry_data.currencies[i].credit[j].id \
 | 
			
		||||
                        = journal_entry.currencies[i].credit[j].id
 | 
			
		||||
        self._set_need_offset({Accounts.RECEIVABLE, Accounts.PAYABLE}, False)
 | 
			
		||||
        self._add_journal_entry(self.j_r_of1)
 | 
			
		||||
        self._add_journal_entry(self.j_r_of2)
 | 
			
		||||
        self._add_journal_entry(self.j_r_of3)
 | 
			
		||||
        self._add_journal_entry(self.j_p_of1)
 | 
			
		||||
        self._add_journal_entry(self.j_p_of2)
 | 
			
		||||
        self._add_journal_entry(self.j_p_of3)
 | 
			
		||||
        self._set_need_offset({Accounts.RECEIVABLE, Accounts.PAYABLE}, True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SameTestData:
 | 
			
		||||
class SameTestData(BaseTestData):
 | 
			
		||||
    """The test data with same descriptions and amounts."""
 | 
			
		||||
 | 
			
		||||
    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(description: str, amount: str, debit: str, credit: str) \
 | 
			
		||||
                -> tuple[JournalEntryLineItemData, JournalEntryLineItemData]:
 | 
			
		||||
            """Returns a couple of debit-credit line items.
 | 
			
		||||
 | 
			
		||||
            :param description: The description.
 | 
			
		||||
            :param amount: The amount.
 | 
			
		||||
            :param debit: The debit account code.
 | 
			
		||||
            :param credit: The credit account code.
 | 
			
		||||
            :return: The debit line item and credit line item.
 | 
			
		||||
            """
 | 
			
		||||
            return JournalEntryLineItemData(debit, description, amount),\
 | 
			
		||||
                JournalEntryLineItemData(credit, description, amount)
 | 
			
		||||
 | 
			
		||||
    def _init_data(self) -> None:
 | 
			
		||||
        # Receivable original line items
 | 
			
		||||
        self.l_r_or1d, self.l_r_or1c = couple(
 | 
			
		||||
            "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or2d, self.l_r_or2c = couple(
 | 
			
		||||
            "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or3d, self.l_r_or3c = couple(
 | 
			
		||||
            "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or4d, self.l_r_or4c = couple(
 | 
			
		||||
            "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or5d, self.l_r_or5c = couple(
 | 
			
		||||
            "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or6d, self.l_r_or6c = couple(
 | 
			
		||||
            "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or1d, self.l_r_or1c = self._add_simple_journal_entry(
 | 
			
		||||
            60, "USD", "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or2d, self.l_r_or2c = self._add_simple_journal_entry(
 | 
			
		||||
            50, "USD", "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or3d, self.l_r_or3c = self._add_simple_journal_entry(
 | 
			
		||||
            40, "USD", "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or4d, self.l_r_or4c = self._add_simple_journal_entry(
 | 
			
		||||
            30, "USD", "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or5d, self.l_r_or5c = self._add_simple_journal_entry(
 | 
			
		||||
            20, "USD", "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or6d, self.l_r_or6c = self._add_simple_journal_entry(
 | 
			
		||||
            10, "USD", "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
 | 
			
		||||
        # Payable original line items
 | 
			
		||||
        self.l_p_or1d, self.l_p_or1c = couple(
 | 
			
		||||
            "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or2d, self.l_p_or2c = couple(
 | 
			
		||||
            "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or3d, self.l_p_or3c = couple(
 | 
			
		||||
            "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or4d, self.l_p_or4c = couple(
 | 
			
		||||
            "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or5d, self.l_p_or5c = couple(
 | 
			
		||||
            "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or6d, self.l_p_or6c = couple(
 | 
			
		||||
            "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or1d, self.l_p_or1c = self._add_simple_journal_entry(
 | 
			
		||||
            60, "USD", "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or2d, self.l_p_or2c = self._add_simple_journal_entry(
 | 
			
		||||
            50, "USD", "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or3d, self.l_p_or3c = self._add_simple_journal_entry(
 | 
			
		||||
            40, "USD", "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or4d, self.l_p_or4c = self._add_simple_journal_entry(
 | 
			
		||||
            30, "USD", "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or5d, self.l_p_or5c = self._add_simple_journal_entry(
 | 
			
		||||
            20, "USD", "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or6d, self.l_p_or6c = self._add_simple_journal_entry(
 | 
			
		||||
            10, "USD", "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
 | 
			
		||||
        # Original journal entries
 | 
			
		||||
        self.j_r_or1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            60, [CurrencyData("USD", [self.l_r_or1d], [self.l_r_or1c])])
 | 
			
		||||
        self.j_r_or2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            50, [CurrencyData("USD", [self.l_r_or2d], [self.l_r_or2c])])
 | 
			
		||||
        self.j_r_or3: JournalEntryData = JournalEntryData(
 | 
			
		||||
            40, [CurrencyData("USD", [self.l_r_or3d], [self.l_r_or3c])])
 | 
			
		||||
        self.j_r_or4: JournalEntryData = JournalEntryData(
 | 
			
		||||
            30, [CurrencyData("USD", [self.l_r_or4d], [self.l_r_or4c])])
 | 
			
		||||
        self.j_r_or5: JournalEntryData = JournalEntryData(
 | 
			
		||||
            20, [CurrencyData("USD", [self.l_r_or5d], [self.l_r_or5c])])
 | 
			
		||||
        self.j_r_or6: JournalEntryData = JournalEntryData(
 | 
			
		||||
            10, [CurrencyData("USD", [self.l_r_or6d], [self.l_r_or6c])])
 | 
			
		||||
        self.j_p_or1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            60, [CurrencyData("USD", [self.l_p_or1d], [self.l_p_or1c])])
 | 
			
		||||
        self.j_p_or2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            50, [CurrencyData("USD", [self.l_p_or2d], [self.l_p_or2c])])
 | 
			
		||||
        self.j_p_or3: JournalEntryData = JournalEntryData(
 | 
			
		||||
            40, [CurrencyData("USD", [self.l_p_or3d], [self.l_p_or3c])])
 | 
			
		||||
        self.j_p_or4: JournalEntryData = JournalEntryData(
 | 
			
		||||
            30, [CurrencyData("USD", [self.l_p_or4d], [self.l_p_or4c])])
 | 
			
		||||
        self.j_p_or5: JournalEntryData = JournalEntryData(
 | 
			
		||||
            20, [CurrencyData("USD", [self.l_p_or5d], [self.l_p_or5c])])
 | 
			
		||||
        self.j_p_or6: JournalEntryData = JournalEntryData(
 | 
			
		||||
            10, [CurrencyData("USD", [self.l_p_or6d], [self.l_p_or6c])])
 | 
			
		||||
 | 
			
		||||
        self.__add_journal_entry(self.j_r_or1)
 | 
			
		||||
        self.__add_journal_entry(self.j_r_or2)
 | 
			
		||||
        self.__add_journal_entry(self.j_r_or3)
 | 
			
		||||
        self.__add_journal_entry(self.j_r_or4)
 | 
			
		||||
        self.__add_journal_entry(self.j_r_or5)
 | 
			
		||||
        self.__add_journal_entry(self.j_r_or6)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_or1)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_or2)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_or3)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_or4)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_or5)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_or6)
 | 
			
		||||
        self._set_need_offset({Accounts.RECEIVABLE, Accounts.PAYABLE}, False)
 | 
			
		||||
 | 
			
		||||
        # Receivable offset items
 | 
			
		||||
        self.l_r_of1d, self.l_r_of1c = couple(
 | 
			
		||||
            "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of2d, self.l_r_of2c = couple(
 | 
			
		||||
            "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of3d, self.l_r_of3c = couple(
 | 
			
		||||
        self.l_r_of1d, self.l_r_of1c = self._add_simple_journal_entry(
 | 
			
		||||
            65, "USD", "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of2d, self.l_r_of2c = self._add_simple_journal_entry(
 | 
			
		||||
            35, "USD", "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of3d, self.l_r_of3c = self._couple(
 | 
			
		||||
            "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of3c.original_line_item = self.l_r_or2d
 | 
			
		||||
        self.l_r_of4d, self.l_r_of4c = couple(
 | 
			
		||||
            "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of5d, self.l_r_of5c = couple(
 | 
			
		||||
            "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of6d, self.l_r_of6c = couple(
 | 
			
		||||
            "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        j_r_of3: JournalEntryData = JournalEntryData(
 | 
			
		||||
            35, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_r_of3d], [self.l_r_of3c])])
 | 
			
		||||
        self.l_r_of4d, self.l_r_of4c = self._add_simple_journal_entry(
 | 
			
		||||
            35, "USD", "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of5d, self.l_r_of5c = self._add_simple_journal_entry(
 | 
			
		||||
            35, "USD", "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of6d, self.l_r_of6c = self._add_simple_journal_entry(
 | 
			
		||||
            15, "USD", "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
 | 
			
		||||
        # Payable offset items
 | 
			
		||||
        self.l_p_of1d, self.l_p_of1c = couple(
 | 
			
		||||
            "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of2d, self.l_p_of2c = couple(
 | 
			
		||||
            "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of3d, self.l_p_of3c = couple(
 | 
			
		||||
        self.l_p_of1d, self.l_p_of1c = self._add_simple_journal_entry(
 | 
			
		||||
            65, "USD", "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of2d, self.l_p_of2c = self._add_simple_journal_entry(
 | 
			
		||||
            35, "USD", "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of3d, self.l_p_of3c = self._couple(
 | 
			
		||||
            "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of3d.original_line_item = self.l_p_or2c
 | 
			
		||||
        self.l_p_of4d, self.l_p_of4c = couple(
 | 
			
		||||
            "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of5d, self.l_p_of5c = couple(
 | 
			
		||||
            "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of6d, self.l_p_of6c = couple(
 | 
			
		||||
            "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        j_p_of3: JournalEntryData = JournalEntryData(
 | 
			
		||||
            35, [JournalEntryCurrencyData(
 | 
			
		||||
                "USD", [self.l_p_of3d], [self.l_p_of3c])])
 | 
			
		||||
        self.l_p_of4d, self.l_p_of4c = self._add_simple_journal_entry(
 | 
			
		||||
            35, "USD", "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of5d, self.l_p_of5c = self._add_simple_journal_entry(
 | 
			
		||||
            35, "USD", "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of6d, self.l_p_of6c = self._add_simple_journal_entry(
 | 
			
		||||
            15, "USD", "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
 | 
			
		||||
        # Offset journal entries
 | 
			
		||||
        self.j_r_of1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            65, [CurrencyData("USD", [self.l_r_of1d], [self.l_r_of1c])])
 | 
			
		||||
        self.j_r_of2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            35, [CurrencyData("USD", [self.l_r_of2d], [self.l_r_of2c])])
 | 
			
		||||
        self.j_r_of3: JournalEntryData = JournalEntryData(
 | 
			
		||||
            35, [CurrencyData("USD", [self.l_r_of3d], [self.l_r_of3c])])
 | 
			
		||||
        self.j_r_of4: JournalEntryData = JournalEntryData(
 | 
			
		||||
            35, [CurrencyData("USD", [self.l_r_of4d], [self.l_r_of4c])])
 | 
			
		||||
        self.j_r_of5: JournalEntryData = JournalEntryData(
 | 
			
		||||
            35, [CurrencyData("USD", [self.l_r_of5d], [self.l_r_of5c])])
 | 
			
		||||
        self.j_r_of6: JournalEntryData = JournalEntryData(
 | 
			
		||||
            15, [CurrencyData("USD", [self.l_r_of6d], [self.l_r_of6c])])
 | 
			
		||||
        self.j_p_of1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            65, [CurrencyData("USD", [self.l_p_of1d], [self.l_p_of1c])])
 | 
			
		||||
        self.j_p_of2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            35, [CurrencyData("USD", [self.l_p_of2d], [self.l_p_of2c])])
 | 
			
		||||
        self.j_p_of3: JournalEntryData = JournalEntryData(
 | 
			
		||||
            35, [CurrencyData("USD", [self.l_p_of3d], [self.l_p_of3c])])
 | 
			
		||||
        self.j_p_of4: JournalEntryData = JournalEntryData(
 | 
			
		||||
            35, [CurrencyData("USD", [self.l_p_of4d], [self.l_p_of4c])])
 | 
			
		||||
        self.j_p_of5: JournalEntryData = JournalEntryData(
 | 
			
		||||
            35, [CurrencyData("USD", [self.l_p_of5d], [self.l_p_of5c])])
 | 
			
		||||
        self.j_p_of6: JournalEntryData = JournalEntryData(
 | 
			
		||||
            15, [CurrencyData("USD", [self.l_p_of6d], [self.l_p_of6c])])
 | 
			
		||||
 | 
			
		||||
        self.__set_is_need_offset(False)
 | 
			
		||||
        self.__add_journal_entry(self.j_r_of1)
 | 
			
		||||
        self.__add_journal_entry(self.j_r_of2)
 | 
			
		||||
        self.__add_journal_entry(self.j_r_of4)
 | 
			
		||||
        self.__add_journal_entry(self.j_r_of5)
 | 
			
		||||
        self.__add_journal_entry(self.j_r_of6)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_of1)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_of2)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_of4)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_of5)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_of6)
 | 
			
		||||
        self.__set_is_need_offset(True)
 | 
			
		||||
        self.__add_journal_entry(self.j_r_of3)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_of3)
 | 
			
		||||
 | 
			
		||||
    def __set_is_need_offset(self, is_need_offset: bool) -> None:
 | 
			
		||||
        """Sets whether the payables and receivables need offset.
 | 
			
		||||
 | 
			
		||||
        :param is_need_offset: True if payables and receivables need offset, or
 | 
			
		||||
            False otherwise.
 | 
			
		||||
        :return:
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Account
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            for code in {Accounts.RECEIVABLE, Accounts.PAYABLE}:
 | 
			
		||||
                account: Account | None = Account.find_by_code(code)
 | 
			
		||||
                assert account is not None
 | 
			
		||||
                account.is_need_offset = is_need_offset
 | 
			
		||||
            db.session.commit()
 | 
			
		||||
 | 
			
		||||
    def __add_journal_entry(self, journal_entry_data: JournalEntryData) \
 | 
			
		||||
            -> None:
 | 
			
		||||
        """Adds a journal entry.
 | 
			
		||||
 | 
			
		||||
        :param journal_entry_data: The journal entry data.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import JournalEntry
 | 
			
		||||
        store_uri: str = "/accounting/journal-entries/store/transfer"
 | 
			
		||||
 | 
			
		||||
        response: httpx.Response = self.client.post(
 | 
			
		||||
            store_uri, data=journal_entry_data.new_form(self.csrf_token))
 | 
			
		||||
        assert response.status_code == 302
 | 
			
		||||
        journal_entry_id: int \
 | 
			
		||||
            = match_journal_entry_detail(response.headers["Location"])
 | 
			
		||||
        journal_entry_data.id = journal_entry_id
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            journal_entry: JournalEntry | None \
 | 
			
		||||
                = db.session.get(JournalEntry, journal_entry_id)
 | 
			
		||||
            assert journal_entry is not None
 | 
			
		||||
            for i in range(len(journal_entry.currencies)):
 | 
			
		||||
                for j in range(len(journal_entry.currencies[i].debit)):
 | 
			
		||||
                    journal_entry_data.currencies[i].debit[j].id \
 | 
			
		||||
                        = journal_entry.currencies[i].debit[j].id
 | 
			
		||||
                for j in range(len(journal_entry.currencies[i].credit)):
 | 
			
		||||
                    journal_entry_data.currencies[i].credit[j].id \
 | 
			
		||||
                        = journal_entry.currencies[i].credit[j].id
 | 
			
		||||
        self._set_need_offset({Accounts.RECEIVABLE, Accounts.PAYABLE}, True)
 | 
			
		||||
        self._add_journal_entry(j_r_of3)
 | 
			
		||||
        self._add_journal_entry(j_p_of3)
 | 
			
		||||
							
								
								
									
										275
									
								
								tests/testlib.py
									
									
									
									
									
								
							
							
						
						
									
										275
									
								
								tests/testlib.py
									
									
									
									
									
								
							@@ -17,12 +17,19 @@
 | 
			
		||||
"""The common test libraries.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
import re
 | 
			
		||||
import typing as t
 | 
			
		||||
from abc import ABC, abstractmethod
 | 
			
		||||
from datetime import date, timedelta
 | 
			
		||||
 | 
			
		||||
from _decimal import Decimal
 | 
			
		||||
 | 
			
		||||
import httpx
 | 
			
		||||
from flask import Flask, render_template_string
 | 
			
		||||
 | 
			
		||||
from test_site import create_app
 | 
			
		||||
from test_site import create_app, db
 | 
			
		||||
 | 
			
		||||
TEST_SERVER: str = "https://testserver"
 | 
			
		||||
"""The test server URI."""
 | 
			
		||||
@@ -117,3 +124,269 @@ def set_locale(client: httpx.Client, csrf_token: str,
 | 
			
		||||
                                                 "next": "/next"})
 | 
			
		||||
    assert response.status_code == 302
 | 
			
		||||
    assert response.headers["Location"] == "/next"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def add_journal_entry(client: httpx.Client, form: dict[str, str]) -> int:
 | 
			
		||||
    """Adds a transfer journal entry.
 | 
			
		||||
 | 
			
		||||
    :param client: The client.
 | 
			
		||||
    :param form: The form data.
 | 
			
		||||
    :return: The newly-added journal entry ID.
 | 
			
		||||
    """
 | 
			
		||||
    prefix: str = "/accounting/journal-entries"
 | 
			
		||||
    journal_entry_type: str = "transfer"
 | 
			
		||||
    if len({x for x in form if "-debit-" in x}) == 0:
 | 
			
		||||
        journal_entry_type = "receipt"
 | 
			
		||||
    elif len({x for x in form if "-credit-" in x}) == 0:
 | 
			
		||||
        journal_entry_type = "disbursement"
 | 
			
		||||
    store_uri = f"{prefix}/store/{journal_entry_type}"
 | 
			
		||||
    response: httpx.Response = client.post(store_uri, data=form)
 | 
			
		||||
    assert response.status_code == 302
 | 
			
		||||
    return match_journal_entry_detail(response.headers["Location"])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def match_journal_entry_detail(location: str) -> int:
 | 
			
		||||
    """Validates if the redirect location is the journal entry detail, and
 | 
			
		||||
    returns the journal entry ID on success.
 | 
			
		||||
 | 
			
		||||
    :param location: The redirect location.
 | 
			
		||||
    :return: The journal entry ID.
 | 
			
		||||
    :raise AssertionError: When the location is not the journal entry detail.
 | 
			
		||||
    """
 | 
			
		||||
    m: re.Match = re.match(
 | 
			
		||||
        r"^/accounting/journal-entries/(\d+)\?next=%2F_next", location)
 | 
			
		||||
    assert m is not None
 | 
			
		||||
    return int(m.group(1))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class JournalEntryLineItemData:
 | 
			
		||||
    """The journal entry line item data."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, account: str, description: str, amount: str,
 | 
			
		||||
                 original_line_item: JournalEntryLineItemData | None = None):
 | 
			
		||||
        """Constructs the journal entry line item data.
 | 
			
		||||
 | 
			
		||||
        :param account: The account code.
 | 
			
		||||
        :param description: The description.
 | 
			
		||||
        :param amount: The amount.
 | 
			
		||||
        :param original_line_item: The original journal entry line item.
 | 
			
		||||
        """
 | 
			
		||||
        self.journal_entry: JournalEntryData | None = None
 | 
			
		||||
        self.id: int = -1
 | 
			
		||||
        self.no: int = -1
 | 
			
		||||
        self.original_line_item: JournalEntryLineItemData | None \
 | 
			
		||||
            = original_line_item
 | 
			
		||||
        self.account: str = account
 | 
			
		||||
        self.description: str = description
 | 
			
		||||
        self.amount: Decimal = Decimal(amount)
 | 
			
		||||
 | 
			
		||||
    def form(self, prefix: str, debit_credit: str, index: int,
 | 
			
		||||
             is_update: bool) -> dict[str, str]:
 | 
			
		||||
        """Returns the line item as form data.
 | 
			
		||||
 | 
			
		||||
        :param prefix: The prefix of the form fields.
 | 
			
		||||
        :param debit_credit: Either "debit" or "credit".
 | 
			
		||||
        :param index: The line item index.
 | 
			
		||||
        :param is_update: True for an update operation, or False otherwise
 | 
			
		||||
        :return: The form data.
 | 
			
		||||
        """
 | 
			
		||||
        prefix = f"{prefix}-{debit_credit}-{index}"
 | 
			
		||||
        form: dict[str, str] = {f"{prefix}-account_code": self.account,
 | 
			
		||||
                                f"{prefix}-description": self.description,
 | 
			
		||||
                                f"{prefix}-amount": str(self.amount)}
 | 
			
		||||
        if is_update and self.id != -1:
 | 
			
		||||
            form[f"{prefix}-id"] = str(self.id)
 | 
			
		||||
        form[f"{prefix}-no"] = str(index) if self.no == -1 else str(self.no)
 | 
			
		||||
        if self.original_line_item is not None:
 | 
			
		||||
            assert self.original_line_item.id != -1
 | 
			
		||||
            form[f"{prefix}-original_line_item_id"] \
 | 
			
		||||
                = str(self.original_line_item.id)
 | 
			
		||||
        return form
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class JournalEntryCurrencyData:
 | 
			
		||||
    """The journal entry currency data."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, currency: str, debit: list[JournalEntryLineItemData],
 | 
			
		||||
                 credit: list[JournalEntryLineItemData]):
 | 
			
		||||
        """Constructs the journal entry currency data.
 | 
			
		||||
 | 
			
		||||
        :param currency: The currency code.
 | 
			
		||||
        :param debit: The debit line items.
 | 
			
		||||
        :param credit: The credit line items.
 | 
			
		||||
        """
 | 
			
		||||
        self.code: str = currency
 | 
			
		||||
        self.debit: list[JournalEntryLineItemData] = debit
 | 
			
		||||
        self.credit: list[JournalEntryLineItemData] = 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 JournalEntryData:
 | 
			
		||||
    """The journal entry data."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, days: int, currencies: list[JournalEntryCurrencyData]):
 | 
			
		||||
        """Constructs a journal entry.
 | 
			
		||||
 | 
			
		||||
        :param days: The number of days before today.
 | 
			
		||||
        :param currencies: The journal entry currency data.
 | 
			
		||||
        """
 | 
			
		||||
        self.id: int = -1
 | 
			
		||||
        self.days: int = days
 | 
			
		||||
        self.currencies: list[JournalEntryCurrencyData] = currencies
 | 
			
		||||
        self.note: str | None = None
 | 
			
		||||
        for currency in self.currencies:
 | 
			
		||||
            for line_item in currency.debit:
 | 
			
		||||
                line_item.journal_entry = self
 | 
			
		||||
            for line_item in currency.credit:
 | 
			
		||||
                line_item.journal_entry = self
 | 
			
		||||
 | 
			
		||||
    def new_form(self, csrf_token: str) -> dict[str, str]:
 | 
			
		||||
        """Returns the journal entry as a creation form.
 | 
			
		||||
 | 
			
		||||
        :param csrf_token: The CSRF token.
 | 
			
		||||
        :return: The journal entry as a creation form.
 | 
			
		||||
        """
 | 
			
		||||
        return self.__form(csrf_token, is_update=False)
 | 
			
		||||
 | 
			
		||||
    def update_form(self, csrf_token: str) -> dict[str, str]:
 | 
			
		||||
        """Returns the journal entry as an update form.
 | 
			
		||||
 | 
			
		||||
        :param csrf_token: The CSRF token.
 | 
			
		||||
        :return: The journal entry as an update form.
 | 
			
		||||
        """
 | 
			
		||||
        return self.__form(csrf_token, is_update=True)
 | 
			
		||||
 | 
			
		||||
    def __form(self, csrf_token: str, is_update: bool = False) \
 | 
			
		||||
            -> dict[str, str]:
 | 
			
		||||
        """Returns the journal entry as a form.
 | 
			
		||||
 | 
			
		||||
        :param csrf_token: The CSRF token.
 | 
			
		||||
        :param is_update: True for an update operation, or False otherwise
 | 
			
		||||
        :return: The journal entry as a form.
 | 
			
		||||
        """
 | 
			
		||||
        journal_entry_date: date = date.today() - timedelta(days=self.days)
 | 
			
		||||
        form: dict[str, str] = {"csrf_token": csrf_token,
 | 
			
		||||
                                "next": NEXT_URI,
 | 
			
		||||
                                "date": journal_entry_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 BaseTestData(ABC):
 | 
			
		||||
    """The base 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
 | 
			
		||||
        self._init_data()
 | 
			
		||||
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
    def _init_data(self) -> None:
 | 
			
		||||
        """Initializes the test data.
 | 
			
		||||
 | 
			
		||||
        :return: None
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def _couple(description: str, amount: str, debit: str, credit: str) \
 | 
			
		||||
            -> tuple[JournalEntryLineItemData, JournalEntryLineItemData]:
 | 
			
		||||
        """Returns a couple of debit-credit line items.
 | 
			
		||||
 | 
			
		||||
        :param description: The description.
 | 
			
		||||
        :param amount: The amount.
 | 
			
		||||
        :param debit: The debit account code.
 | 
			
		||||
        :param credit: The credit account code.
 | 
			
		||||
        :return: The debit line item and credit line item.
 | 
			
		||||
        """
 | 
			
		||||
        return JournalEntryLineItemData(debit, description, amount),\
 | 
			
		||||
            JournalEntryLineItemData(credit, description, amount)
 | 
			
		||||
 | 
			
		||||
    def _add_journal_entry(self, journal_entry_data: JournalEntryData) -> None:
 | 
			
		||||
        """Adds a journal entry.
 | 
			
		||||
 | 
			
		||||
        :param journal_entry_data: The journal entry data.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import JournalEntry
 | 
			
		||||
        store_uri: str = "/accounting/journal-entries/store/transfer"
 | 
			
		||||
 | 
			
		||||
        response: httpx.Response = self.client.post(
 | 
			
		||||
            store_uri, data=journal_entry_data.new_form(self.csrf_token))
 | 
			
		||||
        assert response.status_code == 302
 | 
			
		||||
        journal_entry_id: int \
 | 
			
		||||
            = match_journal_entry_detail(response.headers["Location"])
 | 
			
		||||
        journal_entry_data.id = journal_entry_id
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            journal_entry: JournalEntry | None \
 | 
			
		||||
                = db.session.get(JournalEntry, journal_entry_id)
 | 
			
		||||
            assert journal_entry is not None
 | 
			
		||||
            for i in range(len(journal_entry.currencies)):
 | 
			
		||||
                for j in range(len(journal_entry.currencies[i].debit)):
 | 
			
		||||
                    journal_entry_data.currencies[i].debit[j].id \
 | 
			
		||||
                        = journal_entry.currencies[i].debit[j].id
 | 
			
		||||
                for j in range(len(journal_entry.currencies[i].credit)):
 | 
			
		||||
                    journal_entry_data.currencies[i].credit[j].id \
 | 
			
		||||
                        = journal_entry.currencies[i].credit[j].id
 | 
			
		||||
 | 
			
		||||
    def _add_simple_journal_entry(
 | 
			
		||||
            self, days: int, currency: str, description: str, amount: str,
 | 
			
		||||
            debit: str, credit: str) \
 | 
			
		||||
            -> tuple[JournalEntryLineItemData, JournalEntryLineItemData]:
 | 
			
		||||
        """Adds a simple journal entry.
 | 
			
		||||
 | 
			
		||||
        :param days: The number of days before today.
 | 
			
		||||
        :param currency: The currency code.
 | 
			
		||||
        :param description: The description.
 | 
			
		||||
        :param amount: The amount.
 | 
			
		||||
        :param debit: The debit account code.
 | 
			
		||||
        :param credit: The credit account code.
 | 
			
		||||
        :return: The debit line item and credit line item.
 | 
			
		||||
        """
 | 
			
		||||
        debit_item, credit_item = self._couple(
 | 
			
		||||
            description, amount, debit, credit)
 | 
			
		||||
        self._add_journal_entry(JournalEntryData(
 | 
			
		||||
            days, [JournalEntryCurrencyData(
 | 
			
		||||
                currency, [debit_item], [credit_item])]))
 | 
			
		||||
        return debit_item, credit_item
 | 
			
		||||
 | 
			
		||||
    def _set_need_offset(self, account_codes: set[str],
 | 
			
		||||
                         is_need_offset: bool) -> None:
 | 
			
		||||
        """Sets whether the line items in some accounts need offset.
 | 
			
		||||
 | 
			
		||||
        :param account_codes: The account codes.
 | 
			
		||||
        :param is_need_offset: True if the line items in the accounts need
 | 
			
		||||
            offset, or False otherwise.
 | 
			
		||||
        :return:
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Account
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            for code in account_codes:
 | 
			
		||||
                account: Account | None = Account.find_by_code(code)
 | 
			
		||||
                assert account is not None
 | 
			
		||||
                account.is_need_offset = is_need_offset
 | 
			
		||||
            db.session.commit()
 | 
			
		||||
 
 | 
			
		||||
@@ -18,11 +18,10 @@
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import re
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
from datetime import date
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
from secrets import randbelow
 | 
			
		||||
 | 
			
		||||
import httpx
 | 
			
		||||
from flask import Flask
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
@@ -375,39 +374,6 @@ def __get_currency_prefix(form: dict[str, str], code: str) -> str:
 | 
			
		||||
    return m.group(1)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def add_journal_entry(client: httpx.Client, form: dict[str, str]) -> int:
 | 
			
		||||
    """Adds a transfer journal entry.
 | 
			
		||||
 | 
			
		||||
    :param client: The client.
 | 
			
		||||
    :param form: The form data.
 | 
			
		||||
    :return: The newly-added journal entry ID.
 | 
			
		||||
    """
 | 
			
		||||
    prefix: str = "/accounting/journal-entries"
 | 
			
		||||
    journal_entry_type: str = "transfer"
 | 
			
		||||
    if len({x for x in form if "-debit-" in x}) == 0:
 | 
			
		||||
        journal_entry_type = "receipt"
 | 
			
		||||
    elif len({x for x in form if "-credit-" in x}) == 0:
 | 
			
		||||
        journal_entry_type = "disbursement"
 | 
			
		||||
    store_uri = f"{prefix}/store/{journal_entry_type}"
 | 
			
		||||
    response: httpx.Response = client.post(store_uri, data=form)
 | 
			
		||||
    assert response.status_code == 302
 | 
			
		||||
    return match_journal_entry_detail(response.headers["Location"])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def match_journal_entry_detail(location: str) -> int:
 | 
			
		||||
    """Validates if the redirect location is the journal entry detail, and
 | 
			
		||||
    returns the journal entry ID on success.
 | 
			
		||||
 | 
			
		||||
    :param location: The redirect location.
 | 
			
		||||
    :return: The journal entry ID.
 | 
			
		||||
    :raise AssertionError: When the location is not the journal entry detail.
 | 
			
		||||
    """
 | 
			
		||||
    m: re.Match = re.match(
 | 
			
		||||
        r"^/accounting/journal-entries/(\d+)\?next=%2F_next", location)
 | 
			
		||||
    assert m is not None
 | 
			
		||||
    return int(m.group(1))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def set_negative_amount(form: dict[str, str]) -> None:
 | 
			
		||||
    """Sets a negative amount in the form data, keeping the balance.
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,315 +0,0 @@
 | 
			
		||||
# The Mia! Accounting 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
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
import httpx
 | 
			
		||||
from flask import Flask
 | 
			
		||||
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import NEXT_URI, Accounts
 | 
			
		||||
from testlib_journal_entry import match_journal_entry_detail
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class JournalEntryLineItemData:
 | 
			
		||||
    """The journal entry line item data."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, account: str, description: str, amount: str,
 | 
			
		||||
                 original_line_item: JournalEntryLineItemData | None = None):
 | 
			
		||||
        """Constructs the journal entry line item data.
 | 
			
		||||
 | 
			
		||||
        :param account: The account code.
 | 
			
		||||
        :param description: The description.
 | 
			
		||||
        :param amount: The amount.
 | 
			
		||||
        :param original_line_item: The original journal entry line item.
 | 
			
		||||
        """
 | 
			
		||||
        self.journal_entry: JournalEntryData | None = None
 | 
			
		||||
        self.id: int = -1
 | 
			
		||||
        self.no: int = -1
 | 
			
		||||
        self.original_line_item: JournalEntryLineItemData | None \
 | 
			
		||||
            = original_line_item
 | 
			
		||||
        self.account: str = account
 | 
			
		||||
        self.description: str = description
 | 
			
		||||
        self.amount: Decimal = Decimal(amount)
 | 
			
		||||
 | 
			
		||||
    def form(self, prefix: str, debit_credit: str, index: int,
 | 
			
		||||
             is_update: bool) -> dict[str, str]:
 | 
			
		||||
        """Returns the line item as form data.
 | 
			
		||||
 | 
			
		||||
        :param prefix: The prefix of the form fields.
 | 
			
		||||
        :param debit_credit: Either "debit" or "credit".
 | 
			
		||||
        :param index: The line item index.
 | 
			
		||||
        :param is_update: True for an update operation, or False otherwise
 | 
			
		||||
        :return: The form data.
 | 
			
		||||
        """
 | 
			
		||||
        prefix = f"{prefix}-{debit_credit}-{index}"
 | 
			
		||||
        form: dict[str, str] = {f"{prefix}-account_code": self.account,
 | 
			
		||||
                                f"{prefix}-description": self.description,
 | 
			
		||||
                                f"{prefix}-amount": str(self.amount)}
 | 
			
		||||
        if is_update and self.id != -1:
 | 
			
		||||
            form[f"{prefix}-id"] = str(self.id)
 | 
			
		||||
        form[f"{prefix}-no"] = str(index) if self.no == -1 else str(self.no)
 | 
			
		||||
        if self.original_line_item is not None:
 | 
			
		||||
            assert self.original_line_item.id != -1
 | 
			
		||||
            form[f"{prefix}-original_line_item_id"] \
 | 
			
		||||
                = str(self.original_line_item.id)
 | 
			
		||||
        return form
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CurrencyData:
 | 
			
		||||
    """The journal entry currency data."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, currency: str, debit: list[JournalEntryLineItemData],
 | 
			
		||||
                 credit: list[JournalEntryLineItemData]):
 | 
			
		||||
        """Constructs the journal entry currency data.
 | 
			
		||||
 | 
			
		||||
        :param currency: The currency code.
 | 
			
		||||
        :param debit: The debit line items.
 | 
			
		||||
        :param credit: The credit line items.
 | 
			
		||||
        """
 | 
			
		||||
        self.code: str = currency
 | 
			
		||||
        self.debit: list[JournalEntryLineItemData] = debit
 | 
			
		||||
        self.credit: list[JournalEntryLineItemData] = 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 JournalEntryData:
 | 
			
		||||
    """The journal entry data."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, days: int, currencies: list[CurrencyData]):
 | 
			
		||||
        """Constructs a journal entry.
 | 
			
		||||
 | 
			
		||||
        :param days: The number of days before today.
 | 
			
		||||
        :param currencies: The journal entry 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 line_item in currency.debit:
 | 
			
		||||
                line_item.journal_entry = self
 | 
			
		||||
            for line_item in currency.credit:
 | 
			
		||||
                line_item.journal_entry = self
 | 
			
		||||
 | 
			
		||||
    def new_form(self, csrf_token: str) -> dict[str, str]:
 | 
			
		||||
        """Returns the journal entry as a creation form.
 | 
			
		||||
 | 
			
		||||
        :param csrf_token: The CSRF token.
 | 
			
		||||
        :return: The journal entry as a creation form.
 | 
			
		||||
        """
 | 
			
		||||
        return self.__form(csrf_token, is_update=False)
 | 
			
		||||
 | 
			
		||||
    def update_form(self, csrf_token: str) -> dict[str, str]:
 | 
			
		||||
        """Returns the journal entry as an update form.
 | 
			
		||||
 | 
			
		||||
        :param csrf_token: The CSRF token.
 | 
			
		||||
        :return: The journal entry as an update form.
 | 
			
		||||
        """
 | 
			
		||||
        return self.__form(csrf_token, is_update=True)
 | 
			
		||||
 | 
			
		||||
    def __form(self, csrf_token: str, is_update: bool = False) \
 | 
			
		||||
            -> dict[str, str]:
 | 
			
		||||
        """Returns the journal entry as a form.
 | 
			
		||||
 | 
			
		||||
        :param csrf_token: The CSRF token.
 | 
			
		||||
        :param is_update: True for an update operation, or False otherwise
 | 
			
		||||
        :return: The journal entry as a form.
 | 
			
		||||
        """
 | 
			
		||||
        journal_entry_date: date = date.today() - timedelta(days=self.days)
 | 
			
		||||
        form: dict[str, str] = {"csrf_token": csrf_token,
 | 
			
		||||
                                "next": NEXT_URI,
 | 
			
		||||
                                "date": journal_entry_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(description: str, amount: str, debit: str, credit: str) \
 | 
			
		||||
                -> tuple[JournalEntryLineItemData, JournalEntryLineItemData]:
 | 
			
		||||
            """Returns a couple of debit-credit line items.
 | 
			
		||||
 | 
			
		||||
            :param description: The description.
 | 
			
		||||
            :param amount: The amount.
 | 
			
		||||
            :param debit: The debit account code.
 | 
			
		||||
            :param credit: The credit account code.
 | 
			
		||||
            :return: The debit line item and credit line item.
 | 
			
		||||
            """
 | 
			
		||||
            return JournalEntryLineItemData(debit, description, amount),\
 | 
			
		||||
                JournalEntryLineItemData(credit, description, amount)
 | 
			
		||||
 | 
			
		||||
        # Receivable original line items
 | 
			
		||||
        self.l_r_or1d, self.l_r_or1c = couple(
 | 
			
		||||
            "Accountant", "1200", Accounts.RECEIVABLE, Accounts.SERVICE)
 | 
			
		||||
        self.l_r_or2d, self.l_r_or2c = couple(
 | 
			
		||||
            "Toy", "600", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or3d, self.l_r_or3c = couple(
 | 
			
		||||
            "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
 | 
			
		||||
        self.l_r_or4d, self.l_r_or4c = couple(
 | 
			
		||||
            "Interest", "3.4", Accounts.RECEIVABLE, Accounts.INTEREST)
 | 
			
		||||
 | 
			
		||||
        # Payable original line items
 | 
			
		||||
        self.l_p_or1d, self.l_p_or1c = couple(
 | 
			
		||||
            "Airplane", "2000", Accounts.TRAVEL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or2d, self.l_p_or2c = couple(
 | 
			
		||||
            "Phone", "900", Accounts.OFFICE, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or3d, self.l_p_or3c = couple(
 | 
			
		||||
            "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
 | 
			
		||||
        self.l_p_or4d, self.l_p_or4c = couple(
 | 
			
		||||
            "Envelop", "0.9", Accounts.OFFICE, Accounts.PAYABLE)
 | 
			
		||||
 | 
			
		||||
        # Original journal entries
 | 
			
		||||
        self.j_r_or1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            50, [CurrencyData("USD", [self.l_r_or1d, self.l_r_or4d],
 | 
			
		||||
                              [self.l_r_or1c, self.l_r_or4c])])
 | 
			
		||||
        self.j_r_or2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            30, [CurrencyData("USD", [self.l_r_or2d, self.l_r_or3d],
 | 
			
		||||
                              [self.l_r_or2c, self.l_r_or3c])])
 | 
			
		||||
        self.j_p_or1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            40, [CurrencyData("USD", [self.l_p_or1d, self.l_p_or4d],
 | 
			
		||||
                              [self.l_p_or1c, self.l_p_or4c])])
 | 
			
		||||
        self.j_p_or2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            20, [CurrencyData("USD", [self.l_p_or2d, self.l_p_or3d],
 | 
			
		||||
                              [self.l_p_or2c, self.l_p_or3c])])
 | 
			
		||||
 | 
			
		||||
        self.__add_journal_entry(self.j_r_or1)
 | 
			
		||||
        self.__add_journal_entry(self.j_r_or2)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_or1)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_or2)
 | 
			
		||||
 | 
			
		||||
        # Receivable offset items
 | 
			
		||||
        self.l_r_of1d, self.l_r_of1c = couple(
 | 
			
		||||
            "Accountant", "500", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of1c.original_line_item = self.l_r_or1d
 | 
			
		||||
        self.l_r_of2d, self.l_r_of2c = couple(
 | 
			
		||||
            "Accountant", "200", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of2c.original_line_item = self.l_r_or1d
 | 
			
		||||
        self.l_r_of3d, self.l_r_of3c = couple(
 | 
			
		||||
            "Accountant", "100", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of3c.original_line_item = self.l_r_or1d
 | 
			
		||||
        self.l_r_of4d, self.l_r_of4c = couple(
 | 
			
		||||
            "Toy", "240", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of4c.original_line_item = self.l_r_or2d
 | 
			
		||||
        self.l_r_of5d, self.l_r_of5c = couple(
 | 
			
		||||
            "Interest", "3.4", Accounts.CASH, Accounts.RECEIVABLE)
 | 
			
		||||
        self.l_r_of5c.original_line_item = self.l_r_or4d
 | 
			
		||||
 | 
			
		||||
        # Payable offset items
 | 
			
		||||
        self.l_p_of1d, self.l_p_of1c = couple(
 | 
			
		||||
            "Airplane", "800", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of1d.original_line_item = self.l_p_or1c
 | 
			
		||||
        self.l_p_of2d, self.l_p_of2c = couple(
 | 
			
		||||
            "Airplane", "300", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of2d.original_line_item = self.l_p_or1c
 | 
			
		||||
        self.l_p_of3d, self.l_p_of3c = couple(
 | 
			
		||||
            "Airplane", "100", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of3d.original_line_item = self.l_p_or1c
 | 
			
		||||
        self.l_p_of4d, self.l_p_of4c = couple(
 | 
			
		||||
            "Phone", "400", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of4d.original_line_item = self.l_p_or2c
 | 
			
		||||
        self.l_p_of5d, self.l_p_of5c = couple(
 | 
			
		||||
            "Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH)
 | 
			
		||||
        self.l_p_of5d.original_line_item = self.l_p_or4c
 | 
			
		||||
 | 
			
		||||
        # Offset journal entries
 | 
			
		||||
        self.j_r_of1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            25, [CurrencyData("USD", [self.l_r_of1d], [self.l_r_of1c])])
 | 
			
		||||
        self.j_r_of2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            20, [CurrencyData("USD",
 | 
			
		||||
                              [self.l_r_of2d, self.l_r_of3d, self.l_r_of4d],
 | 
			
		||||
                              [self.l_r_of2c, self.l_r_of3c, self.l_r_of4c])])
 | 
			
		||||
        self.j_r_of3: JournalEntryData = JournalEntryData(
 | 
			
		||||
            15, [CurrencyData("USD", [self.l_r_of5d], [self.l_r_of5c])])
 | 
			
		||||
        self.j_p_of1: JournalEntryData = JournalEntryData(
 | 
			
		||||
            15, [CurrencyData("USD", [self.l_p_of1d], [self.l_p_of1c])])
 | 
			
		||||
        self.j_p_of2: JournalEntryData = JournalEntryData(
 | 
			
		||||
            10, [CurrencyData("USD",
 | 
			
		||||
                              [self.l_p_of2d, self.l_p_of3d, self.l_p_of4d],
 | 
			
		||||
                              [self.l_p_of2c, self.l_p_of3c, self.l_p_of4c])])
 | 
			
		||||
        self.j_p_of3: JournalEntryData = JournalEntryData(
 | 
			
		||||
            5, [CurrencyData("USD", [self.l_p_of5d], [self.l_p_of5c])])
 | 
			
		||||
 | 
			
		||||
        self.__add_journal_entry(self.j_r_of1)
 | 
			
		||||
        self.__add_journal_entry(self.j_r_of2)
 | 
			
		||||
        self.__add_journal_entry(self.j_r_of3)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_of1)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_of2)
 | 
			
		||||
        self.__add_journal_entry(self.j_p_of3)
 | 
			
		||||
 | 
			
		||||
    def __add_journal_entry(self, journal_entry_data: JournalEntryData) \
 | 
			
		||||
            -> None:
 | 
			
		||||
        """Adds a journal entry.
 | 
			
		||||
 | 
			
		||||
        :param journal_entry_data: The journal entry data.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import JournalEntry
 | 
			
		||||
        store_uri: str = "/accounting/journal-entries/store/transfer"
 | 
			
		||||
 | 
			
		||||
        response: httpx.Response = self.client.post(
 | 
			
		||||
            store_uri, data=journal_entry_data.new_form(self.csrf_token))
 | 
			
		||||
        assert response.status_code == 302
 | 
			
		||||
        journal_entry_id: int \
 | 
			
		||||
            = match_journal_entry_detail(response.headers["Location"])
 | 
			
		||||
        journal_entry_data.id = journal_entry_id
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            journal_entry: JournalEntry | None \
 | 
			
		||||
                = db.session.get(JournalEntry, journal_entry_id)
 | 
			
		||||
            assert journal_entry is not None
 | 
			
		||||
            for i in range(len(journal_entry.currencies)):
 | 
			
		||||
                for j in range(len(journal_entry.currencies[i].debit)):
 | 
			
		||||
                    journal_entry_data.currencies[i].debit[j].id \
 | 
			
		||||
                        = journal_entry.currencies[i].debit[j].id
 | 
			
		||||
                for j in range(len(journal_entry.currencies[i].credit)):
 | 
			
		||||
                    journal_entry_data.currencies[i].credit[j].id \
 | 
			
		||||
                        = journal_entry.currencies[i].credit[j].id
 | 
			
		||||
		Reference in New Issue
	
	Block a user