diff --git a/.github/workflows/main.yaml b/.github/workflows/lint.yaml similarity index 89% rename from .github/workflows/main.yaml rename to .github/workflows/lint.yaml index ca0ac0e9..25c8b7d1 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/lint.yaml @@ -9,7 +9,7 @@ env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} jobs: - prettier: + lint: needs: [ py_json_merge ] runs-on: ubuntu-latest steps: @@ -24,6 +24,21 @@ jobs: with: commit_message: "style: prettify code" + - name: Setup Python + uses: actions/setup-python@v3 + with: + python-version: '3.10' + + - name: Install mypy + run: pip install mypy + + - name: Run mypy + uses: sasanquaneuf/mypy-github-action@releases/v1 + with: + checkName: 'mypy' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + json_diff: needs: [ py_json_merge ] runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a07116b9..d7f97931 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -52,6 +52,13 @@ repos: - id: flake8 additional_dependencies: ['flake8-bugbear'] + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.5.1 + hooks: + - id: mypy + exclude: ^tests/ + args: [--ignore-missing-imports] + ci: autoupdate_schedule: weekly skip: [] diff --git a/README.md b/README.md index 67f715b4..7a20cd62 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,74 @@ MIT -### Installation Instructions +### Production Installation Instructions See the [installation guide](./docs/installationguide.md) for detailed instructions for either a production or development environment. + +#### Developer Setup + +First, set up a new bench and substitute a path to the python version to use. Python should be 3.10 latest for V14. These instructions use [pyenv](https://github.com/pyenv/pyenv) for managing environments. +```shell +# Version 14 +bench init --frappe-branch version-14 {{ bench name }} --python ~/.pyenv/versions/3.10.3/bin/python3 +``` + +Create a new site in that bench +```shell +cd {{ bench name }} +bench new-site {{ site name }} --force --db-name {{ site name }} +``` + +Download the ERPNext app +```shell +# Version 14 +bench get-app erpnext --branch version-14 +bench get-app hrms --branch version-14 +``` + +Download the Time and Expense application +```shell +bench get-app check_run https://github.com/agritheory/check_run +``` + +Install the apps to your site +```shell +bench --site {{ site name }} install-app erpnext hrms check_run + +# Optional: Check that all apps installed on your site +bench --site {{ site name }} list-apps +``` + +Set developer mode in `site_config.json` +```shell +nano sites/{{ site name }}/site_config.json +# Add this line: + "developer_mode": 1, + +``` +Install pre-commit: +``` +# ~/frappe-bench/apps/check_run/ +pre-commit install +``` + +Add the site to your computer's hosts file to be able to access it via: `http://{{ site name }}:[8000]`. You'll need to enter your root password to allow your command line application to make edits to this file. +```shell +bench --site {{site name}} add-to-hosts +``` + +Launch your bench (note you should be using Node.js v14 for a Version 13 bench and Node.js v16 for a Version 14 bench) +```shell +bench start +``` + +Optional: install a [demo Company and its data](./exampledata.md) to test the Electronic Payments module's functionality +```shell +bench execute 'check_run.tests.setup.before_test' +``` + +To run `mypy` locally: +```shell +source env/bin/activate +mypy ./apps/check_run/check_run --ignore-missing-imports +``` \ No newline at end of file diff --git a/check_run/check_run/__init__.py b/check_run/check_run/__init__.py index 56d1e0d3..c10c1ca3 100644 --- a/check_run/check_run/__init__.py +++ b/check_run/check_run/__init__.py @@ -1,11 +1,18 @@ +# Copyright (c) 2023, AgriTheory and contributors +# For license information, please see license.txt + import json import frappe +from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import PurchaseInvoice +from erpnext.accounts.doctype.journal_entry.journal_entry import JournalEntry +from hrms.hr.doctype.expense_claim.expense_claim import ExpenseClaim + @frappe.whitelist() @frappe.read_only() -def show_bank_account_number(doctype, docname): +def show_bank_account_number(doctype: str, docname: str) -> dict: doc = frappe.get_doc(doctype, docname) routing_number = frappe.get_value("Bank", doc.bank, "aba_number") or "" account_number = doc.get_password("bank_account", raise_exception=False) or "" @@ -13,7 +20,9 @@ def show_bank_account_number(doctype, docname): @frappe.whitelist() -def disallow_cancellation_if_in_check_run(doc, method=None): +def disallow_cancellation_if_in_check_run( + doc: PurchaseInvoice | JournalEntry | ExpenseClaim, method: str | None = None +) -> None: draft_check_runs = frappe.get_all("Check Run", ["name", "transactions"], {"docstatus": 0}) for draft_check_run in draft_check_runs: if not draft_check_run.transactions: diff --git a/check_run/check_run/doctype/check_run/check_run.py b/check_run/check_run/doctype/check_run/check_run.py index 994616da..5ed3e6e5 100644 --- a/check_run/check_run/doctype/check_run/check_run.py +++ b/check_run/check_run/doctype/check_run/check_run.py @@ -5,6 +5,7 @@ import json from itertools import groupby, zip_longest from io import StringIO +from typing_extensions import Self from PyPDF2 import PdfFileWriter @@ -21,14 +22,18 @@ from frappe.query_builder.functions import Coalesce, Sum from erpnext.accounts.utils import get_balance_on +from erpnext.accounts.doctype.payment_entry.payment_entry import PaymentEntry from atnacha import ACHEntry, ACHBatch, NACHAFile -from check_run.check_run.doctype.check_run_settings.check_run_settings import create +from check_run.check_run.doctype.check_run_settings.check_run_settings import ( + CheckRunSettings, + create, +) class CheckRun(Document): @frappe.read_only() - def onload(self): + def onload(self: Self) -> None: if self.is_new(): return settings = get_check_run_settings(self) @@ -43,21 +48,21 @@ def onload(self): else: self.set_onload("check_run_submitting", False) - def validate(self): + def validate(self: Self) -> None: gl_account = frappe.get_value("Bank Account", self.bank_account, "account") if not gl_account: frappe.throw(frappe._("This Bank Account is not associated with a General Ledger Account.")) - self.beg_balance = get_balance_on(gl_account, self.posting_date) + self.beg_balance = get_balance_on(gl_account, self.posting_date) # type: ignore # datetime.date if self.flags.in_insert: - if self.initial_check_number is None: + if self.initial_check_number is None: # type: ignore # int or None self.set_last_check_number() self.set_default_payable_account() self.set_default_dates() else: - if self.status == "Draft": + if self.status == "Draft": # type: ignore # str or None self.filter_transactions() - def on_cancel(self): + def on_cancel(self: Self) -> None: settings = get_check_run_settings(self) if not settings.allow_cancellation: frappe.throw(frappe._("The settings for this Check Run do not allow cancellation")) @@ -72,40 +77,41 @@ def on_cancel(self): for pe in pes: frappe.db.set_value("Payment Entry", pe, "check_run", "") - def on_update_after_submit(self): + def on_update_after_submit(self: Self) -> None: + # required to fire on_update_after_submit hook pass - def set_status(self, status=None): + def set_status(self: Self, status: str | None = None) -> None: if status: self.db_set("status", status) return - elif self.status == "Confirm Print": + elif self.status == "Confirm Print": # type: ignore # str or None pass elif self.docstatus == 0: self.status = "Draft" - elif self.docstatus == 1 and self.print_count > 0: + elif self.docstatus == 1 and self.print_count > 0: # type: ignore # print_count: int self.status = "Printed" elif self.docstatus == 1: self.status = "Submitted" - def set_last_check_number(self): + def set_last_check_number(self: Self) -> None: if self.ach_only().ach_only: return check_number = frappe.get_value("Bank Account", self.bank_account, "check_number") self.initial_check_number = int(check_number or 0) + 1 - def set_default_payable_account(self): - if not self.pay_to_account: + def set_default_payable_account(self: Self) -> None: + if not self.pay_to_account: # type: ignore # str or None self.pay_to_account = frappe.get_value("Company", self.company, "default_payable_account") - def set_default_dates(self): - if not self.posting_date: + def set_default_dates(self: Self) -> None: + if not self.posting_date: # type: ignore # datetime or None self.posting_date = getdate() - if not self.end_date: + if not self.end_date: # type: ignore # datetime or None self.end_date = getdate() @frappe.read_only() - def filter_transactions(self): + def filter_transactions(self: Self) -> None: if not self.get("transactions"): return transactions = json.loads(self.get("transactions")) @@ -119,7 +125,7 @@ def filter_transactions(self): frappe.throw(frappe._(f"Mode of Payment Required: {t['party_name']} {t['ref_number']}")) @frappe.read_only() - def not_outstanding_or_cancelled(self, transaction): + def not_outstanding_or_cancelled(self: Self, transaction: dict) -> bool: filters = { "name": transaction["name"] if transaction["doctype"] != "Journal Entry" @@ -152,9 +158,10 @@ def not_outstanding_or_cancelled(self, transaction): else: if frappe.get_value(transaction["doctype"], filters, "outstanding_amount") == 0.0: return True + return False @frappe.whitelist() - def process_check_run(self): + def process_check_run(self: Self) -> None: check_run_submitting = frappe.defaults.get_global_default("check_run_submitting") if check_run_submitting: frappe.throw( @@ -171,20 +178,20 @@ def process_check_run(self): frappe.throw(frappe._("You must select at least one Invoice to pay.")) self.print_count = 0 if self.ach_only().ach_only: - self.initial_check_number = "" + self.initial_check_number = "" # type: ignore self.final_check_number = "" frappe.enqueue_doc( self.doctype, self.name, "_process_check_run", save=True, queue="short", timeout=3600, now=True ) - def _process_check_run(self, save=False): + def _process_check_run(self: Self, save: bool = False) -> None: frappe.defaults.set_global_default("check_run_submitting", self.name) frappe.db.sql("SAVEPOINT process_check_run") try: - transactions = self.transactions - transactions = json.loads(transactions) + __transactions = self.transactions + _transactions = json.loads(__transactions) transactions = sorted( - (frappe._dict(item) for item in transactions if item.get("pay")), key=lambda x: x.party + (frappe._dict(item) for item in _transactions if item.get("pay")), key=lambda x: x.party ) _transactions = self.create_payment_entries(transactions) except Exception as e: @@ -203,7 +210,7 @@ def _process_check_run(self, save=False): frappe.db.sql("RELEASE SAVEPOINT process_check_run") frappe.publish_realtime("reload", "{}", doctype=self.doctype, docname=self.name) - def build_nacha_file(self, settings=None): + def build_nacha_file(self: Self, settings: CheckRunSettings) -> str: electronic_mop = frappe.get_all( "Mode of Payment", {"type": "Electronic", "enabled": 1}, "name", pluck="name" ) @@ -219,7 +226,7 @@ def build_nacha_file(self, settings=None): @frappe.whitelist() @frappe.read_only() - def ach_only(self): + def ach_only(self: Self) -> bool: transactions = json.loads(self.transactions) if self.transactions else [] ach_only = frappe._dict({"ach_only": True, "print_checks_only": True}) if not self.transactions: @@ -236,7 +243,7 @@ def ach_only(self): ach_only.print_checks_only = False return ach_only - def create_payment_entries(self, transactions): + def create_payment_entries(self: Self, transactions: list[frappe._dict]) -> list[frappe._dict]: settings = get_check_run_settings(self) split = 5 if settings and settings.number_of_invoices_per_voucher: @@ -290,7 +297,7 @@ def create_payment_entries(self, transactions): if frappe.db.get_value("Mode of Payment", _group[0].mode_of_payment, "type") == "Bank": pe.reference_no = int(self.initial_check_number) + check_count check_count += 1 - self.final_check_number = pe.reference_no + self.final_check_number = str(pe.reference_no) else: pe.reference_no = frappe._( f"via {_group[0].mode_of_payment} {self.get_formatted('posting_date')}" @@ -345,7 +352,7 @@ def create_payment_entries(self, transactions): return _transactions @frappe.whitelist() - def increment_print_count(self, reprint_check_number=None): + def increment_print_count(self: Self, reprint_check_number: int | None = None) -> None: frappe.enqueue_doc( self.doctype, self.name, @@ -356,7 +363,7 @@ def increment_print_count(self, reprint_check_number=None): ) @frappe.whitelist() - def render_check_pdf(self, reprint_check_number=None): + def render_check_pdf(self: Self, reprint_check_number: int | None = None) -> None: self.print_count = self.print_count + 1 self.set_status("Submitted") if not frappe.db.exists("File", "Home/Check Run"): @@ -415,7 +422,7 @@ def render_check_pdf(self, reprint_check_number=None): @frappe.whitelist() -def check_for_draft_check_run(company, bank_account, payable_account): +def check_for_draft_check_run(company: str, bank_account: str, payable_account: str) -> str: existing = frappe.get_value( "Check Run", { @@ -436,7 +443,7 @@ def check_for_draft_check_run(company, bank_account, payable_account): @frappe.whitelist() -def confirm_print(docname): +def confirm_print(docname: str) -> None: # Remove PDF file(s) remove_all("Check Run", docname, from_delete=False, delete_permanently=False) @@ -449,7 +456,7 @@ def confirm_print(docname): @frappe.whitelist() @frappe.read_only() -def get_entries(doc): +def get_entries(doc: CheckRun | str) -> dict: doc = frappe._dict(json.loads(doc)) if isinstance(doc, str) else doc if isinstance(doc.end_date, str): doc.end_date = getdate(doc.end_date) @@ -620,16 +627,16 @@ def get_entries(doc): @frappe.whitelist() @frappe.read_only() -def get_balance(doc): +def get_balance(doc: CheckRun | str) -> str: doc = frappe._dict(json.loads(doc)) if isinstance(doc, str) else doc if not doc.bank_account or not doc.posting_date: - return + return "" gl_account = frappe.get_value("Bank Account", doc.bank_account, "account") return get_balance_on(gl_account, doc.posting_date) @frappe.whitelist() -def download_checks(docname): +def download_checks(docname: str) -> str: has_permission( "Payment Entry", ptype="print", verbose=False, user=frappe.session.user, raise_exception=True ) @@ -639,7 +646,7 @@ def download_checks(docname): @frappe.whitelist() -def download_nacha(docname): +def download_nacha(docname: str) -> None: has_permission( "Payment Entry", ptype="print", verbose=False, user=frappe.session.user, raise_exception=True ) @@ -668,7 +675,9 @@ def download_nacha(docname): frappe.db.commit() -def build_nacha_file_from_payment_entries(doc, payment_entries, settings): +def build_nacha_file_from_payment_entries( + doc: CheckRun, payment_entries: list[PaymentEntry], settings: CheckRunSettings +) -> NACHAFile: ach_entries = [] exceptions = [] company_bank = frappe.db.get_value("Bank Account", doc.bank_account, "bank") @@ -751,7 +760,7 @@ def build_nacha_file_from_payment_entries(doc, payment_entries, settings): @frappe.whitelist() -def get_check_run_settings(doc): +def get_check_run_settings(doc: CheckRun | str) -> str: doc = frappe._dict(json.loads(doc)) if isinstance(doc, str) else doc if frappe.db.exists( "Check Run Settings", {"bank_account": doc.bank_account, "pay_to_account": doc.pay_to_account} @@ -763,7 +772,7 @@ def get_check_run_settings(doc): return create(doc.company, doc.bank_account, doc.pay_to_account) -def get_address(party, party_type, doctype, name): +def get_address(party: str, party_type: str, doctype: str, name: str) -> str: if doctype == "Purchase Invoice": return frappe.get_value("Purchase Invoice", name, "supplier_address") elif doctype == "Expense Claim": @@ -773,11 +782,12 @@ def get_address(party, party_type, doctype, name): return get_default_address("Supplier", party) elif party_type == "Employee": return frappe.get_value("Employee", name, "permanent_address") + return "" @frappe.whitelist() @frappe.read_only() -def ach_only(docname): +def ach_only(docname: str) -> dict: if not frappe.db.exists("Check Run", docname): return {"ach_only": False, "checks_only": False} cr = frappe.get_doc("Check Run", docname) @@ -785,6 +795,6 @@ def ach_only(docname): @frappe.whitelist() -def process_check_run(docname): +def process_check_run(docname: str) -> None: doc = frappe.get_doc("Check Run", docname) doc.process_check_run() diff --git a/check_run/check_run/doctype/check_run_settings/check_run_settings.py b/check_run/check_run/doctype/check_run_settings/check_run_settings.py index e15a6c34..0a0938c8 100644 --- a/check_run/check_run/doctype/check_run_settings/check_run_settings.py +++ b/check_run/check_run/doctype/check_run_settings/check_run_settings.py @@ -10,7 +10,7 @@ class CheckRunSettings(Document): @frappe.whitelist() -def create(company, bank_account, pay_to_account): +def create(company: str, bank_account: str, pay_to_account: str) -> str: crs = frappe.new_doc("Check Run Settings") crs.company = company crs.bank_account = bank_account diff --git a/check_run/overrides/bank.py b/check_run/overrides/bank.py index 5dbc5783..802f170c 100644 --- a/check_run/overrides/bank.py +++ b/check_run/overrides/bank.py @@ -1,8 +1,12 @@ +# Copyright (c) 2023, AgriTheory and contributors +# For license information, please see license.txt + import frappe +from erpnext.accounts.doctype.bank_account.bank_account import BankAccount @frappe.whitelist() -def validate(doc, method=None): +def validate(doc: BankAccount, method: str | None = None): # Canadian banking institutions limit DFI Routing Numbers to 8 characters addresses = frappe.qb.DocType("Address") dls = frappe.qb.DocType("Dynamic Link") diff --git a/check_run/overrides/payment_entry.py b/check_run/overrides/payment_entry.py index 1706d377..7ab2da5c 100644 --- a/check_run/overrides/payment_entry.py +++ b/check_run/overrides/payment_entry.py @@ -1,8 +1,12 @@ +# Copyright (c) 2023, AgriTheory and contributors +# For license information, please see license.txt + import frappe +from erpnext.accounts.doctype.payment_entry.payment_entry import PaymentEntry @frappe.whitelist() -def update_check_number(doc, method=None): +def update_check_number(doc: PaymentEntry, method: str | None = None) -> None: mode_of_payment_type = frappe.db.get_value("Mode of Payment", doc.mode_of_payment, "type") if doc.bank_account and mode_of_payment_type == "Bank" and str(doc.reference_no).isdigit(): frappe.db.set_value("Bank Account", doc.bank_account, "check_number", doc.reference_no) diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..af1d1860 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,3 @@ +[mypy] +ignore_missing_imports = True +disable_error_code = annotation-unchecked diff --git a/pyproject.toml b/pyproject.toml index d0ab0a99..e11aad62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,87 +1,85 @@ -[project] -name = "check_run" -authors = [ - { name = "AgriTheory", email = "support@agritheory.dev"} -] -description = "Payables utility for ERPNext" -requires-python = ">=3.10" -readme = "README.md" -dynamic = ["version"] -dependencies = [ - "atnacha @ git+https://github.com/AgriTheory/atnacha.git@main#egg=atnacha" -] - -[build-system] -requires = ["flit_core >=3.4,<4"] -build-backend = "flit_core.buildapi" - -[tool.bench.dev-dependencies] -hypothesis = "~=6.31.0" - -[tool.black] -line-length = 99 - -[tool.isort] -line_length = 99 -multi_line_output = 3 -include_trailing_comma = true -force_grid_wrap = 0 -use_parentheses = true -ensure_newline_before_comments = true -indent = "\t" - -[tool.semantic_release] -assets = [] -commit_message = "{version}\n\nAutomatically generated by python-semantic-release" -commit_parser = "angular" -logging_use_named_masks = false -major_on_zero = true -tag_format = "v{version}" -version_variables = [ - "check_run/__init__.py:__version__", - "pyproject.toml:version" -] - -[tool.semantic_release.branches.version] -match = "version-(13|14)" -prerelease = false - -[tool.semantic_release.changelog] -template_dir = "templates" -changelog_file = "CHANGELOG.md" -exclude_commit_patterns = [] - -[tool.semantic_release.changelog.environment] -block_start_string = "{%" -block_end_string = "%}" -variable_start_string = "{{" -variable_end_string = "}}" -comment_start_string = "{#" -comment_end_string = "#}" -trim_blocks = false -lstrip_blocks = false -newline_sequence = "\n" -keep_trailing_newline = false -extensions = [] -autoescape = true - -[tool.semantic_release.commit_author] -env = "GIT_COMMIT_AUTHOR" -default = "semantic-release " - -[tool.semantic_release.commit_parser_options] -allowed_tags = ["build", "chore", "ci", "docs", "feat", "fix", "perf", "style", "refactor", "test"] -minor_tags = ["feat"] -patch_tags = ["fix", "perf"] - -[tool.semantic_release.remote] -name = "origin" -type = "github" -ignore_token_for_push = false - -[tool.semantic_release.remote.token] -env = "GH_TOKEN" - -[tool.semantic_release.publish] -dist_glob_patterns = ["dist/*"] -upload_to_vcs_release = true +[project] +name = "check_run" +authors = [ + { name = "AgriTheory", email = "support@agritheory.dev"} +] +description = "Payables utility for ERPNext" +requires-python = ">=3.10" +readme = "README.md" +dynamic = ["version"] +dependencies = [ + "atnacha @ git+https://github.com/AgriTheory/atnacha.git@main#egg=atnacha", + "mypy" +] + +[build-system] +requires = ["flit_core >=3.4,<4"] +build-backend = "flit_core.buildapi" + +[tool.black] +line-length = 99 + +[tool.isort] +line_length = 99 +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true +indent = "\t" + +[tool.semantic_release] +assets = [] +commit_message = "{version}\n\nAutomatically generated by python-semantic-release" +commit_parser = "angular" +logging_use_named_masks = false +major_on_zero = true +tag_format = "v{version}" +version_variables = [ + "check_run/__init__.py:__version__", + "pyproject.toml:version" +] + +[tool.semantic_release.branches.version] +match = "version-(13|14)" +prerelease = false + +[tool.semantic_release.changelog] +template_dir = "templates" +changelog_file = "CHANGELOG.md" +exclude_commit_patterns = [] + +[tool.semantic_release.changelog.environment] +block_start_string = "{%" +block_end_string = "%}" +variable_start_string = "{{" +variable_end_string = "}}" +comment_start_string = "{#" +comment_end_string = "#}" +trim_blocks = false +lstrip_blocks = false +newline_sequence = "\n" +keep_trailing_newline = false +extensions = [] +autoescape = true + +[tool.semantic_release.commit_author] +env = "GIT_COMMIT_AUTHOR" +default = "semantic-release " + +[tool.semantic_release.commit_parser_options] +allowed_tags = ["build", "chore", "ci", "docs", "feat", "fix", "perf", "style", "refactor", "test"] +minor_tags = ["feat"] +patch_tags = ["fix", "perf"] + +[tool.semantic_release.remote] +name = "origin" +type = "github" +ignore_token_for_push = false + +[tool.semantic_release.remote.token] +env = "GH_TOKEN" + +[tool.semantic_release.publish] +dist_glob_patterns = ["dist/*"] +upload_to_vcs_release = true