From 840a39b79e340d97652f7fc3a08afe8a0d9bb5ad Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Mon, 3 Mar 2025 11:22:32 +0530 Subject: [PATCH 01/14] fix: multiple fixes for automation --- .../constants/email_template.py | 2 + .../payments_processor_configuration.json | 5 +- .../upcoming_invoice_payment.json | 2 +- .../upcoming_invoice_payment.py | 5 ++ .../payments_processor/utils/automation.py | 46 +++++++++++-------- 5 files changed, 40 insertions(+), 20 deletions(-) diff --git a/payments_processor/payments_processor/constants/email_template.py b/payments_processor/payments_processor/constants/email_template.py index b85583f..9250a64 100644 --- a/payments_processor/payments_processor/constants/email_template.py +++ b/payments_processor/payments_processor/constants/email_template.py @@ -1,3 +1,5 @@ +# TODO: only share the sections available. +# TODO: error with Fmt Money in dev EMAIL_TEMPLATES = [ { "name": "Auto Payment Email", diff --git a/payments_processor/payments_processor/doctype/payments_processor_configuration/payments_processor_configuration.json b/payments_processor/payments_processor/doctype/payments_processor_configuration/payments_processor_configuration.json index c270683..bc34d64 100644 --- a/payments_processor/payments_processor/doctype/payments_processor_configuration/payments_processor_configuration.json +++ b/payments_processor/payments_processor/doctype/payments_processor_configuration/payments_processor_configuration.json @@ -270,6 +270,7 @@ "fieldtype": "Column Break" }, { + "depends_on": "eval: doc.auto_generate_entries", "description": "Email notification will be sent to all users with this role", "fieldname": "email_to", "fieldtype": "Link", @@ -277,6 +278,7 @@ "options": "Role" }, { + "depends_on": "eval: doc.auto_generate_entries", "description": "Time during the day when processing should be done", "fieldname": "processing_time", "fieldtype": "Time", @@ -291,6 +293,7 @@ "fieldtype": "Column Break" }, { + "depends_on": "eval: doc.auto_generate_entries", "fieldname": "last_execution", "fieldtype": "Datetime", "label": "Last Execution", @@ -299,7 +302,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2025-03-01 10:44:27.963980", + "modified": "2025-03-02 08:40:59.207141", "modified_by": "Administrator", "module": "Payments Processor", "name": "Payments Processor Configuration", diff --git a/payments_processor/payments_processor/report/upcoming_invoice_payment/upcoming_invoice_payment.json b/payments_processor/payments_processor/report/upcoming_invoice_payment/upcoming_invoice_payment.json index cd03c24..39a7319 100644 --- a/payments_processor/payments_processor/report/upcoming_invoice_payment/upcoming_invoice_payment.json +++ b/payments_processor/payments_processor/report/upcoming_invoice_payment/upcoming_invoice_payment.json @@ -9,7 +9,7 @@ "idx": 0, "is_standard": "Yes", "letterhead": null, - "modified": "2025-03-01 22:08:57.556866", + "modified": "2025-03-02 12:24:14.752160", "modified_by": "Administrator", "module": "Payments Processor", "name": "Upcoming Invoice Payment", diff --git a/payments_processor/payments_processor/report/upcoming_invoice_payment/upcoming_invoice_payment.py b/payments_processor/payments_processor/report/upcoming_invoice_payment/upcoming_invoice_payment.py index 55de121..d5c2702 100644 --- a/payments_processor/payments_processor/report/upcoming_invoice_payment/upcoming_invoice_payment.py +++ b/payments_processor/payments_processor/report/upcoming_invoice_payment/upcoming_invoice_payment.py @@ -70,6 +70,7 @@ def get_columns() -> list[dict]: "label": _("Reason"), "fieldname": "reason", "fieldtype": "Data", + "width": 300, }, ] @@ -94,6 +95,10 @@ def get_data(filters) -> list[list]: data = [] for setting in auto_pay_settings: + if not setting.auto_generate_entries: + frappe.throw(_("Auto Generate Entries is not enabled")) + return + processed = PaymentsProcessor(setting, filters).process_invoices() for invoices in processed.get("valid", {}).values(): diff --git a/payments_processor/payments_processor/utils/automation.py b/payments_processor/payments_processor/utils/automation.py index 24f9021..12ef51b 100644 --- a/payments_processor/payments_processor/utils/automation.py +++ b/payments_processor/payments_processor/utils/automation.py @@ -3,14 +3,11 @@ from functools import cached_property import frappe -from erpnext.accounts.report.accounts_receivable_summary.accounts_receivable_summary import ( - AccountsReceivableSummary, -) from erpnext.accounts.utils import get_balance_on from frappe import _ from frappe.core.doctype.role.role import get_info_based_on_role from frappe.email.doctype.email_template.email_template import get_email_template -from frappe.utils import add_days, get_timedelta, getdate, now_datetime +from frappe.utils import add_days, fmt_money, get_timedelta, getdate, now_datetime from pypika import Order from payments_processor.constants import CONFIGURATION_DOCTYPE @@ -36,6 +33,9 @@ def autocreate_payment_entry(): auto_pay_settings = frappe.get_all(CONFIGURATION_DOCTYPE, "*", {"disabled": 0}) for setting in auto_pay_settings: + if not setting.auto_generate_entries: + return + if not setting.processing_time: continue @@ -45,6 +45,7 @@ def autocreate_payment_entry(): if setting.last_execution and getdate(setting.last_execution) == getdate(): continue + # TODO: try except PaymentsProcessor(setting).run() frappe.db.set_value( @@ -128,8 +129,7 @@ def get_invoice_group(invoice_group): if self.setting.group_payments_by_supplier: return [invoice_group] - for invoice in invoice_group: - yield [invoice] + return [[invoice] for invoice in invoice_group] def update_payment_info(invoice_group, pe): for invoice in invoice_group: @@ -139,6 +139,7 @@ def update_payment_info(invoice_group, pe): invoice.paid_from_account_currency = pe.paid_from_account_currency for invoice_group in get_invoice_group(supplier_invoices): + print(invoice_group) try: pe = self.create_payment_entry(supplier_name, invoice_group) @@ -235,11 +236,13 @@ def get_invoices(self): .where(doc.company == self.setting.company) .where( # invoice is due (doc.is_return == 1) # immediately claim refund for returns - | ((doc.is_return == 0) & (terms.due_date < self.offset_due_date)) + | ((doc.is_return == 0) & (terms.due_date <= self.offset_due_date)) | ( (doc.is_return == 0) & (terms.discount_date.notnull()) - & (terms.discount_date < self.next_payment_date) + & ( + terms.discount_date <= self.next_payment_date + ) # TODO: -ve offset ) ) .orderby(terms.due_date, order=Order.asc) @@ -285,9 +288,15 @@ def get_invoices(self): 0, term_outstanding + updated.total_outstanding_due ) + # TODO: enhance and check accuracy + if not payment_term.outstanding_amount: + self.invoices.pop(row.name) + continue + self.apply_discount(payment_term) updated.due_date = payment_term.due_date + updated.payment_date = row.payment_date updated.total_outstanding_due += term_outstanding updated.total_discount += payment_term.discount_amount updated.setdefault("payment_terms", []).append(payment_term) @@ -353,6 +362,9 @@ def process_auto_generate(self): for invoice in self.invoices.values(): supplier = self.suppliers.get(invoice.supplier) + invoice.amount_to_pay = ( + invoice.total_outstanding_due - invoice.total_discount + ) # supplier validations if not supplier: @@ -418,7 +430,7 @@ def process_auto_generate(self): supplier = self.suppliers[supplier_name] if msg := self.is_auto_generate_threshold_exceeded(paid_amount): - for invoice in valid.pop(supplier_name): + for invoice in valid[supplier_name]: invoice.update({**msg, "auto_generate": 0}) invalid.setdefault(supplier_name, []).extend(valid.pop(supplier_name)) @@ -458,6 +470,7 @@ def process_auto_submit(self): invoice.update({**msg, "auto_submit": 0}) def create_payment_entry(self, supplier_name, invoice_list): + # TODO: how do we handle failure of payment entry pe = frappe.new_doc("Payment Entry") paid_amount = 0 @@ -525,13 +538,13 @@ def is_invoice_due(self, invoice): invoice.payment_date = self.today return True - if self.is_discount_applicable(invoice.term_discount_date): + if invoice.discount and self.is_discount_applicable(invoice.term_discount_date): invoice.payment_date = self.get_previous_payment_date( invoice.term_discount_date ) return True - if invoice.term_due_date and invoice.term_due_date < self.offset_due_date: + if invoice.term_due_date and invoice.term_due_date <= self.offset_due_date: invoice.payment_date = self.get_previous_payment_date(invoice.term_due_date) return True @@ -575,9 +588,6 @@ def is_auto_generate_disabled(self, supplier): def is_payment_exceeding_supplier_outstanding(self, supplier, invoice): if not self.setting.limit_payment_to_outstanding: - invoice.amount_to_pay = ( - invoice.total_outstanding_due - invoice.total_discount - ) return if amount_to_pay := min( @@ -602,7 +612,7 @@ def is_invoice_blocked(self, invoice): if not invoice.on_hold: return False - if invoice.release_date and invoice.release_date > self.today: + if invoice.release_date and invoice.release_date < self.today: return False return self.get_error_msg("2001") @@ -670,7 +680,7 @@ def is_discount_applicable(self, discount_date): return ( self.setting.claim_early_payment_discount and discount_date - and discount_date < self.next_payment_date + and discount_date <= self.next_payment_date ) def get_next_payment_date(self): @@ -679,7 +689,7 @@ def get_next_payment_date(self): today_index = DAY_NAMES.index(self.today.strftime("%A")) - for i in range(1, 8): + for i in range(0, 7): next_day = DAY_NAMES[(today_index + i) % 7] if next_day in self.automation_days: return add_days(self.today, i) @@ -687,7 +697,7 @@ def get_next_payment_date(self): def get_previous_payment_date(self, due_date): due_date_index = DAY_NAMES.index(due_date.strftime("%A")) - for i in range(1, 8): + for i in range(0, 7): previous_day = DAY_NAMES[(due_date_index - i) % 7] if previous_day in self.automation_days: # subject to max of today From 2e06fa50dc2835fb01c8a5667ee33c25e7aba5bc Mon Sep 17 00:00:00 2001 From: priyanshshah2442 Date: Thu, 6 Mar 2025 11:10:51 +0530 Subject: [PATCH 02/14] fix: test case setup and refactor --- .pre-commit-config.yaml | 38 +-- payments_processor/hooks.py | 4 +- .../payments_processor_configuration.py | 19 +- .../upcoming_invoice_payment.json | 2 +- .../payments_processor/utils/automation.py | 217 ++++++++++-------- .../utils/test_automation.py | 84 +++++++ payments_processor/tests/__init__.py | 34 +++ payments_processor/tests/test_records.json | 110 +++++++++ pyproject.toml | 30 ++- 9 files changed, 405 insertions(+), 133 deletions(-) create mode 100644 payments_processor/payments_processor/utils/test_automation.py create mode 100644 payments_processor/tests/__init__.py create mode 100644 payments_processor/tests/test_records.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6fb640a..7a16e5a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,16 +7,23 @@ repos: rev: v4.5.0 hooks: - id: trailing-whitespace - files: "payments_processor/.*" - exclude: ".*txt$|.*csv|.*md" + files: "payments_processor.*" + exclude: ".*json$|.*txt$|.*csv|.*md" - id: check-yaml - - id: no-commit-to-branch - args: ["--branch", "version-15"] + # - id: no-commit-to-branch + # args: ["--branch", "develop"] - id: check-merge-conflict - id: check-ast - - id: check-json - - id: check-toml - - id: debug-statements + + - repo: https://github.com/psf/black + rev: 24.1.1 + hooks: + - id: black + + - repo: https://github.com/PyCQA/isort + rev: 5.13.2 + hooks: + - id: isort - repo: https://github.com/pre-commit/mirrors-prettier rev: v2.7.1 @@ -47,18 +54,11 @@ repos: .*boilerplate.* )$ - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.2.0 - hooks: - - id: ruff - name: "Run ruff import sorter" - args: ["--select=I", "--fix"] - - - id: ruff - name: "Run ruff linter" - - - id: ruff-format - name: "Run ruff formatter" + # - repo: https://github.com/PyCQA/flake8 + # rev: 7.0.0 + # hooks: + # - id: flake8 + # additional_dependencies: [flake8-isort, flake8-bugbear] ci: autoupdate_schedule: weekly diff --git a/payments_processor/hooks.py b/payments_processor/hooks.py index c97daf5..935efca 100644 --- a/payments_processor/hooks.py +++ b/payments_processor/hooks.py @@ -8,7 +8,9 @@ after_install = "payments_processor.install.after_install" before_uninstall = "payments_processor.uninstall.before_uninstall" -# TODO: Make this comfigurable +before_tests = "payments_processor.tests.before_tests" + +# TODO: Make this configurable scheduler_events = { "all": [ "payments_processor.payments_processor.utils.automation.autocreate_payment_entry" diff --git a/payments_processor/payments_processor/doctype/payments_processor_configuration/payments_processor_configuration.py b/payments_processor/payments_processor/doctype/payments_processor_configuration/payments_processor_configuration.py index 1782dc2..8a5e3b6 100644 --- a/payments_processor/payments_processor/doctype/payments_processor_configuration/payments_processor_configuration.py +++ b/payments_processor/payments_processor/doctype/payments_processor_configuration/payments_processor_configuration.py @@ -1,9 +1,11 @@ # Copyright (c) 2024, Resilient Tech and contributors # For license information, please see license.txt -import frappe +import frappe # noqa: I001 from frappe import _ from frappe.model.document import Document +from frappe.utils import get_link_to_form +from erpnext import get_default_cost_center # Auto Payment Setting # Payouts not required @@ -49,6 +51,7 @@ def validate(self): return self.validate_default_discount_account() + self.validate_default_cost_center() self.validate_automation_days() def set_defaults(self): @@ -73,6 +76,20 @@ def validate_default_discount_account(self): ) ) + def validate_default_cost_center(self): + if not self.claim_early_payment_discount: + return + + if not get_default_cost_center(self.company): + frappe.throw( + title=_("Default Cost Center Required"), + msg=_( + "Please set a default Cost Center in the Company {0} settings to claim early payment discounts.".format( # noqa: UP030 + frappe.bold(get_link_to_form("Company", self.company)) + ) + ), + ) + def validate_automation_days(self): automation_days = [ self.automate_on_monday, diff --git a/payments_processor/payments_processor/report/upcoming_invoice_payment/upcoming_invoice_payment.json b/payments_processor/payments_processor/report/upcoming_invoice_payment/upcoming_invoice_payment.json index 39a7319..2ab610a 100644 --- a/payments_processor/payments_processor/report/upcoming_invoice_payment/upcoming_invoice_payment.json +++ b/payments_processor/payments_processor/report/upcoming_invoice_payment/upcoming_invoice_payment.json @@ -9,7 +9,7 @@ "idx": 0, "is_standard": "Yes", "letterhead": null, - "modified": "2025-03-02 12:24:14.752160", + "modified": "2025-03-05 11:33:16.772144", "modified_by": "Administrator", "module": "Payments Processor", "name": "Upcoming Invoice Payment", diff --git a/payments_processor/payments_processor/utils/automation.py b/payments_processor/payments_processor/utils/automation.py index 12ef51b..e13e85a 100644 --- a/payments_processor/payments_processor/utils/automation.py +++ b/payments_processor/payments_processor/utils/automation.py @@ -1,14 +1,16 @@ -import calendar +import calendar # noqa: I001 from collections import defaultdict from functools import cached_property +from pypika import Order + import frappe -from erpnext.accounts.utils import get_balance_on from frappe import _ from frappe.core.doctype.role.role import get_info_based_on_role from frappe.email.doctype.email_template.email_template import get_email_template -from frappe.utils import add_days, fmt_money, get_timedelta, getdate, now_datetime -from pypika import Order +from frappe.utils import add_days, get_timedelta, getdate, now_datetime +from erpnext import get_default_cost_center +from erpnext.accounts.utils import get_balance_on from payments_processor.constants import CONFIGURATION_DOCTYPE from payments_processor.payments_processor.constants.roles import ROLE_PROFILE @@ -39,14 +41,14 @@ def autocreate_payment_entry(): if not setting.processing_time: continue - if setting.processing_time > time_now(): - continue + # if setting.processing_time > time_now(): + # continue - if setting.last_execution and getdate(setting.last_execution) == getdate(): - continue + # if setting.last_execution and getdate(setting.last_execution) == getdate(): + # continue # TODO: try except - PaymentsProcessor(setting).run() + PaymentsProcessor(setting, frappe._dict({"payment_date": "2025-03-02"})).run() frappe.db.set_value( CONFIGURATION_DOCTYPE, setting.name, "last_execution", frappe.utils.now() @@ -139,7 +141,6 @@ def update_payment_info(invoice_group, pe): invoice.paid_from_account_currency = pe.paid_from_account_currency for invoice_group in get_invoice_group(supplier_invoices): - print(invoice_group) try: pe = self.create_payment_entry(supplier_name, invoice_group) @@ -155,7 +156,9 @@ def update_payment_info(invoice_group, pe): except Exception: self.handle_pe_creation_failed(supplier_name) frappe.log_error( - title=f"Error saving automated payment entry for supplier {supplier_name}", + title=_( + "Error saving automated payment entry for supplier {0})" + ).format(supplier_name), message=frappe.get_traceback(), ) @@ -205,47 +208,53 @@ def get_invoices(self): ... } """ - doc = frappe.qb.DocType("Purchase Invoice") - terms = frappe.qb.DocType("Payment Schedule") + + PI = frappe.qb.DocType("Purchase Invoice") + PI_TERMS = frappe.qb.DocType("Payment Schedule") + invoices = ( - frappe.qb.from_(doc) - .join(terms) - .on((doc.name == terms.parent) & (terms.parenttype == "Purchase Invoice")) + frappe.qb.from_(PI) + .join(PI_TERMS) + .on( + (PI.name == PI_TERMS.parent) + & (PI_TERMS.parenttype == "Purchase Invoice") + ) .select( - doc.name, - doc.company, - doc.supplier, - doc.outstanding_amount, - doc.grand_total, - doc.rounded_total, - doc.currency, - doc.contact_person, - doc.bill_no, - doc.is_return, - doc.on_hold, - doc.hold_comment, - doc.release_date, - terms.due_date.as_("term_due_date"), - terms.outstanding.as_("term_outstanding_amount"), - terms.discount_date.as_("term_discount_date"), - terms.discount_type.as_("term_discount_type"), - terms.discount.as_("term_discount"), + PI.name, + PI.company, + PI.supplier, + PI.outstanding_amount, + PI.grand_total, + PI.rounded_total, + PI.currency, + PI.contact_person, + PI.bill_no, + PI.is_return, + PI.on_hold, + PI.hold_comment, + PI.release_date, + PI.cost_center, + PI_TERMS.due_date.as_("term_due_date"), + PI_TERMS.outstanding.as_("term_outstanding_amount"), + PI_TERMS.discount_date.as_("term_discount_date"), + PI_TERMS.discount_type.as_("term_discount_type"), + PI_TERMS.discount.as_("term_discount"), ) - .where(doc.docstatus == 1) - .where(doc.outstanding_amount != 0) - .where(doc.company == self.setting.company) + .where(PI.docstatus == 1) + .where(PI.outstanding_amount != 0) + .where(PI.company == self.setting.company) .where( # invoice is due - (doc.is_return == 1) # immediately claim refund for returns - | ((doc.is_return == 0) & (terms.due_date <= self.offset_due_date)) + (PI.is_return == 1) # immediately claim refund for returns + | ((PI.is_return == 0) & (PI_TERMS.due_date <= self.offset_due_date)) | ( - (doc.is_return == 0) - & (terms.discount_date.notnull()) + (PI.is_return == 0) + & (PI_TERMS.discount_date.notnull()) & ( - terms.discount_date <= self.next_payment_date + PI_TERMS.discount_date >= self.next_payment_date ) # TODO: -ve offset ) ) - .orderby(terms.due_date, order=Order.asc) + .orderby(PI_TERMS.due_date, order=Order.asc) .run(as_dict=True) ) @@ -302,20 +311,23 @@ def get_invoices(self): updated.setdefault("payment_terms", []).append(payment_term) def get_suppliers(self): - suppliers = frappe.get_all( - "Supplier", - filters={"name": ("in", [row.supplier for row in self.invoices.values()])}, - fields=( - "name", - "disabled", - "on_hold", - "hold_type", - "release_date", - "disable_auto_generate_payment_entry", - ), - ) - - self.suppliers = {supplier.name: supplier for supplier in suppliers} + self.suppliers = { + supplier.name: supplier + for supplier in frappe.get_all( + "Supplier", + filters={ + "name": ("in", [row.supplier for row in self.invoices.values()]) + }, + fields=( + "name", + "disabled", + "on_hold", + "hold_type", + "release_date", + "disable_auto_generate_payment_entry", + ), + ) + } def update_supplier_outstanding(self): if not self.setting.limit_payment_to_outstanding: @@ -348,7 +360,9 @@ def update_supplier_outstanding(self): company=self.setting.company, ) - supplier.remaining_balance = outstanding * -1 - pe_map.get(supplier.name, 0) + supplier.remaining_balance = (outstanding * -1) - pe_map.get( + supplier.name, 0 + ) def process_auto_generate(self): if not self.setting.auto_generate_entries: @@ -360,6 +374,24 @@ def process_auto_generate(self): invalid = self.processed_invoices.setdefault("invalid", frappe._dict()) valid = self.processed_invoices.setdefault("valid", frappe._dict()) + supplier_checks = [ + lambda sup, _: self.is_supplier_disabled(sup), + lambda sup, _: self.is_supplier_blocked(sup), + lambda sup, _: self.is_auto_generate_disabled(sup), + lambda _, inv: self.payment_entry_exists(inv), + lambda sup, inv: self.is_payment_exceeding_supplier_outstanding(sup, inv), + lambda _, inv: ( + self.is_auto_generate_threshold_exceeded(inv.amount_to_pay) + if not self.setting.group_payments_by_supplier + else None + ), + ] + + invoice_checks = [ + self.is_invoice_blocked, + self.exclude_foreign_currency_invoices, + ] + for invoice in self.invoices.values(): supplier = self.suppliers.get(invoice.supplier) invoice.amount_to_pay = ( @@ -373,52 +405,33 @@ def process_auto_generate(self): ) continue - if msg := self.is_supplier_disabled(supplier): - invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) - continue - - if msg := self.is_supplier_blocked(supplier): - invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) - continue - - if msg := self.is_auto_generate_disabled(supplier): - invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) - continue - - # run before outstanding check (for better error message) - # since outstanding amount is adjusted based on draft PEs - if msg := self.payment_entry_exists(invoice): - invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) - continue - - if msg := self.is_payment_exceeding_supplier_outstanding(supplier, invoice): - invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) - continue + continue_outer = False + for check in supplier_checks: + if msg := check(supplier, invoice): + invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) + continue_outer = True + break # No need to check further if already invalid - if not self.setting.group_payments_by_supplier and ( - msg := self.is_auto_generate_threshold_exceeded(invoice.amount_to_pay) - ): - invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) + if continue_outer: continue # invoice validations - if msg := self.is_invoice_blocked(invoice): - invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) - continue + continue_outer = False + for check in invoice_checks: + if msg := check(invoice): + invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) + continue_outer = True + break - if msg := self.exclude_foreign_currency_invoices(invoice): - invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) + if continue_outer: continue - functions = frappe.get_hooks("filter_auto_generate_payments") - for fn in functions: + for fn in frappe.get_hooks("filter_auto_generate_payments"): if msg := frappe.call(fn, supplier=supplier, invoice=invoice): invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) break - else: self.supplier_paid_amount[invoice.supplier] += invoice.amount_to_pay - invoice.auto_generate = 1 valid.setdefault(invoice.supplier, []).append(invoice) @@ -450,8 +463,7 @@ def process_auto_submit(self): invoice.update(msg) continue - functions = frappe.get_hooks("filter_auto_submit_payments") - for fn in functions: + for fn in frappe.get_hooks("filter_auto_submit_payments"): if msg := frappe.call(fn, supplier=supplier, invoice=invoice): invoice.update(msg) break @@ -518,13 +530,18 @@ def create_payment_entry(self, supplier_name, invoice_list): return pe if not self.discount_account: - frappe.throw("Default discount account is not set in Company") + frappe.throw(_("Default discount account is not set in Company")) + + default_cost_center = get_default_cost_center(self.setting.company) + + if not default_cost_center: + frappe.throw(_("Default Cost Center is not set in Company")) pe.append( "deductions", { "account": self.discount_account, - "cost_center": invoice.cost_center, # TODO: could be different for each invoice + "cost_center": default_cost_center, # TODO: could be different for each invoice (for now we are using Default Cost Center as specified in Company) "amount": total_discount, }, ) @@ -538,7 +555,9 @@ def is_invoice_due(self, invoice): invoice.payment_date = self.today return True - if invoice.discount and self.is_discount_applicable(invoice.term_discount_date): + if invoice.term_discount and self.is_discount_applicable( + invoice.term_discount_date + ): invoice.payment_date = self.get_previous_payment_date( invoice.term_discount_date ) @@ -572,7 +591,7 @@ def is_supplier_blocked(self, supplier): if not supplier.on_hold: return False - if supplier.hold_type not in ["All", "Payments"]: + if supplier.hold_type not in {"All", "Payments"}: return False if supplier.release_date and supplier.release_date > self.today: @@ -680,7 +699,7 @@ def is_discount_applicable(self, discount_date): return ( self.setting.claim_early_payment_discount and discount_date - and discount_date <= self.next_payment_date + and self.next_payment_date <= discount_date ) def get_next_payment_date(self): diff --git a/payments_processor/payments_processor/utils/test_automation.py b/payments_processor/payments_processor/utils/test_automation.py new file mode 100644 index 0000000..1ef6b6c --- /dev/null +++ b/payments_processor/payments_processor/utils/test_automation.py @@ -0,0 +1,84 @@ +import frappe +from frappe.tests import IntegrationTestCase + +INVOICES = [ + { + "supplier": "Needs Quick Money Ltd", + "items": [ + {"item_code": "Anything and Everything Item", "rate": 10000.0, "qty": 1.0} + ], + "payment_terms_template": "Test Payment Term Template", + }, + { + "supplier": "Messy Books Pvt Ltd", + "items": [ + {"item_code": "Anything and Everything Item", "rate": 8000.0, "qty": 1.0} + ], + }, + { + "supplier": "Complex Terms LLP", + "items": [ + {"item_code": "Anything and Everything Item", "rate": 10000.0, "qty": 1.0} + ], + }, + { + "supplier": "Honest Consultant", + "items": [ + {"item_code": "Anything and Everything Item", "rate": 10000.0, "qty": 1.0} + ], + }, + { + "supplier": "Honest Consultant", + "items": [ + {"item_code": "Anything and Everything Item", "rate": 90000.0, "qty": 1.0} + ], + }, + { + "supplier": "Honest Consultant", + "items": [ + {"item_code": "Anything and Everything Item", "rate": 80000.0, "qty": 1.0} + ], + }, + { + "supplier": "Eco Stationery", + "items": [ + {"item_code": "Anything and Everything Item", "rate": 6000.0, "qty": 1.0} + ], + }, + { + "supplier": "Defective Goods LLP", + "items": [ + {"item_code": "Anything and Everything Item", "rate": 11000.0, "qty": 1.0} + ], + }, + { + "supplier": "Always Non-Compliant", + "items": [ + {"item_code": "Anything and Everything Item", "rate": 11000.0, "qty": 1.0} + ], + }, + { + "supplier": "Common Party Pvt Ltd", + "items": [ + {"item_code": "Anything and Everything Item", "rate": 27000.0, "qty": 1.0} + ], + }, + { + "supplier": "Common Party Pvt Ltd", + "items": [ + {"item_code": "Anything and Everything Item", "rate": 26000.0, "qty": 1.0} + ], + }, +] + + +class TestPaymentsProcessor(IntegrationTestCase): + @classmethod + def setUpClass(cls): + return super().setUpClass() + + def create_test_records(self): + pass + + def test_invoice(self): + pass diff --git a/payments_processor/tests/__init__.py b/payments_processor/tests/__init__.py new file mode 100644 index 0000000..f29f4af --- /dev/null +++ b/payments_processor/tests/__init__.py @@ -0,0 +1,34 @@ +import frappe +from frappe.tests.utils import make_test_objects + + +def before_tests(): + frappe.clear_cache() + + create_test_records() + set_default_company_for_tests() + frappe.db.commit() + + +def create_test_records(): + test_records = frappe.get_file_json( + frappe.get_app_path("payments_processor", "tests", "test_records.json") + ) + + for doctype, data in test_records.items(): + make_test_objects(doctype, data) + + +def set_default_company_for_tests(): + frappe.db.set_value( + "Company", + "_Test Company", + { + "default_discount_account": "Discount Allowed - TC", + }, + ) + + # set default company + global_defaults = frappe.get_single("Global Defaults") + global_defaults.default_company = "_Test Company" + global_defaults.save() diff --git a/payments_processor/tests/test_records.json b/payments_processor/tests/test_records.json new file mode 100644 index 0000000..7f4116b --- /dev/null +++ b/payments_processor/tests/test_records.json @@ -0,0 +1,110 @@ +{ + "Company": [ + { + "abbr": "_TC", + "company_name": "_Test Company", + "country": "India", + "default_currency": "INR", + "doctype": "Company", + "domain": "Manufacturing", + "chart_of_accounts": "Standard", + "enable_perpetual_inventory": 0 + } + ], + "Item": [ + { + "description": "_Test Sample Item", + "doctype": "Item", + "is_stock_item": 1, + "item_code": "_Test Sample Item", + "item_name": "_Test Sample Item", + "item_group": "All Item Groups", + "valuation_rate": 100, + "stock_uom": "Nos", + "uoms": [ + { + "conversion_factor": 1, + "uom": "Nos", + "name": "_Test Sample Item" + } + ], + "item_defaults": [ + { + "name": "_Test Sample Item", + "company": "_Test Company", + "default_warehouse": "Stores - _TC", + "expense_account": "Cost of Goods Sold - _TC", + "buying_cost_center": "Main - _TC", + "selling_cost_center": "Main - _TC", + "income_account": "Sales - _TC" + } + ] + } + ], + "Payment Term": [ + { + "payment_term_name": "Test 1P Discount", + "invoice_portion": 100.0, + "due_date_based_on": "Day(s) after invoice date", + "credit_days": 30, + "discount_type": "Percentage", + "discount_validity_based_on": "Day(s) after invoice date", + "discount": 5.0, + "discount_validity": 7 + } + ], + "Payment Terms Template": [ + { + "template_name": "Test Payment Term Template", + "terms": [ + { + "payment_term": "Test 1P Discount", + "invoice_portion": 100.0 + } + ] + } + ], + "Supplier": [ + { + "name": "Common Party Pvt Ltd", + "supplier_name": "Common Party Pvt Ltd", + "supplier_type": "Company" + }, + { + "name": "Always Non-Compliant", + "supplier_name": "Always Non-Compliant", + "supplier_type": "Company" + }, + { + "name": "Defective Goods LLP", + "supplier_name": "Defective Goods LLP", + "supplier_type": "Company" + }, + { + "name": "Eco Stationery", + "supplier_name": "Eco Stationery", + "supplier_type": "Company" + }, + { + "name": "Honest Consultant", + "supplier_name": "Honest Consultant", + "supplier_type": "Company" + }, + { + "name": "Needs Quick Money Ltd", + "supplier_name": "Needs Quick Money Ltd", + "supplier_type": "Company", + "payment_terms": "Test Payment Term Template" + }, + { + "name": "Complex Terms LLP", + "supplier_name": "Complex Terms LLP", + "supplier_type": "Company" + }, + { + "name": "Messy Books Pvt Ltd", + "supplier_name": "Messy Books Pvt Ltd", + "supplier_type": "Company" + } + ] +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 8637e91..c9a190e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,6 @@ [project] name = "payments_processor" -authors = [ - { name = "Resilient Tech", email = "info@resilient.tech"} -] +authors = [{ name = "Resilient Tech", email = "info@resilient.tech" }] description = "Automates the creation of Payment Entries and handles payments." requires-python = ">=3.10" readme = "README.md" @@ -16,15 +14,7 @@ requires = ["flit_core >=3.4,<4"] build-backend = "flit_core.buildapi" [tool.ruff.lint] -select = [ - "F", - "E", - "W", - "I", - "UP", - "B", - "RUF", -] +select = ["F", "E", "W", "I", "UP", "B", "RUF"] ignore = [ "E501", # line too long @@ -47,3 +37,19 @@ docstring-code-format = true [tool.bench.frappe-dependencies] frappe = ">=15.0.0,<16.0.0" erpnext = ">=15.0.0,<16.0.0" + + +[tool.isort] +profile = "black" +known_frappe = "frappe" +known_erpnext = "erpnext" +no_lines_before = ["ERPNEXT"] +sections = [ + "FUTURE", + "STDLIB", + "THIRDPARTY", + "FRAPPE", + "ERPNEXT", + "FIRSTPARTY", + "LOCALFOLDER", +] From a901f0239e19f6e41983e41999911df8b45ba821 Mon Sep 17 00:00:00 2001 From: priyanshshah2442 Date: Thu, 6 Mar 2025 15:32:13 +0530 Subject: [PATCH 03/14] fix: linters --- payments_processor/install.py | 1 + .../payments_processor_configuration.py | 6 ++---- payments_processor/setup.py | 5 ++--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/payments_processor/install.py b/payments_processor/install.py index 00df217..e1cb9a1 100644 --- a/payments_processor/install.py +++ b/payments_processor/install.py @@ -1,4 +1,5 @@ import click + import frappe from payments_processor.constants import BUG_REPORT_URL diff --git a/payments_processor/payments_processor/doctype/payments_processor_configuration/payments_processor_configuration.py b/payments_processor/payments_processor/doctype/payments_processor_configuration/payments_processor_configuration.py index 8a5e3b6..88e1257 100644 --- a/payments_processor/payments_processor/doctype/payments_processor_configuration/payments_processor_configuration.py +++ b/payments_processor/payments_processor/doctype/payments_processor_configuration/payments_processor_configuration.py @@ -84,10 +84,8 @@ def validate_default_cost_center(self): frappe.throw( title=_("Default Cost Center Required"), msg=_( - "Please set a default Cost Center in the Company {0} settings to claim early payment discounts.".format( # noqa: UP030 - frappe.bold(get_link_to_form("Company", self.company)) - ) - ), + "Please set a default Cost Center in the Company {0} settings to claim early payment discounts." + ).format(frappe.bold(get_link_to_form("Company", self.company))), ) def validate_automation_days(self): diff --git a/payments_processor/setup.py b/payments_processor/setup.py index a05d741..80eec61 100644 --- a/payments_processor/setup.py +++ b/payments_processor/setup.py @@ -1,12 +1,11 @@ import click + import frappe from frappe.custom.doctype.custom_field.custom_field import ( create_custom_fields as make_custom_fields, ) -from payments_processor.payments_processor.constants.custom_fields import ( - CUSTOM_FIELDS, -) +from payments_processor.payments_processor.constants.custom_fields import CUSTOM_FIELDS from payments_processor.payments_processor.constants.email_template import ( EMAIL_TEMPLATES, ) From 3d3b5c56bc4985eddb5ef087e8a7e42726bc1c51 Mon Sep 17 00:00:00 2001 From: priyanshshah2442 Date: Mon, 10 Mar 2025 16:06:41 +0530 Subject: [PATCH 04/14] fix: test class setup and some test cases --- .../payments_processor/utils/automation.py | 3 + .../utils/test_automation.py | 204 ++++++++++++++---- payments_processor/tests/test_records.json | 32 +++ payments_processor/tests/utils.py | 27 +++ 4 files changed, 224 insertions(+), 42 deletions(-) create mode 100644 payments_processor/tests/utils.py diff --git a/payments_processor/payments_processor/utils/automation.py b/payments_processor/payments_processor/utils/automation.py index e13e85a..2b9a21f 100644 --- a/payments_processor/payments_processor/utils/automation.py +++ b/payments_processor/payments_processor/utils/automation.py @@ -61,6 +61,9 @@ def time_now(): class PaymentsProcessor: def __init__(self, setting, filters=None): + if isinstance(setting, str): + setting = frappe.get_doc(CONFIGURATION_DOCTYPE, setting) + self.setting = setting self.filters = filters or frappe._dict() diff --git a/payments_processor/payments_processor/utils/test_automation.py b/payments_processor/payments_processor/utils/test_automation.py index 1ef6b6c..aac65c9 100644 --- a/payments_processor/payments_processor/utils/test_automation.py +++ b/payments_processor/payments_processor/utils/test_automation.py @@ -1,84 +1,204 @@ -import frappe +import frappe # noqa: I001 from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, today -INVOICES = [ +from payments_processor.constants import CONFIGURATION_DOCTYPE +from payments_processor.payments_processor.report.upcoming_invoice_payment.upcoming_invoice_payment import ( + execute, +) +from payments_processor.tests.utils import change_settings as _change_settings + +TEST_COMPANY = "_Test Company" + +DISCOUNT_INVOICES = [ { "supplier": "Needs Quick Money Ltd", - "items": [ - {"item_code": "Anything and Everything Item", "rate": 10000.0, "qty": 1.0} - ], - "payment_terms_template": "Test Payment Term Template", - }, + "item_code": "Anything and Everything Item", + "rate": 10000.0, + "qty": 1.0, + "due_date": add_days(today(), 30), + } +] +EXPECTED_DISCOUNTED_INVOICE_DATA = [ + { + "supplier": "Needs Quick Money Ltd", + "on_hold": 0, + "hold_comment": None, + "amount_to_pay": 9900.0, + "auto_generate": 1, + "auto_submit": 0, + "reason": "Payment submission threshold exceeded", + "reason_code": "1021", + } +] + +BLOCKED_SUPPLIER_INVOICES = [ + { + "supplier": "Always Non-Compliant", + "item_code": "Anything and Everything Item", + "rate": 11000.0, + "qty": 1.0, + } +] + +EXPECTED_BLOCKED_SUPPLIER_INVOICE_DATA = [ + { + "supplier": "Always Non-Compliant", + "on_hold": 0, + "hold_comment": None, + "amount_to_pay": 11000.0, + "reason": "Payments to supplier are blocked", + "reason_code": "1002", + } +] + +INVOICES = [ { "supplier": "Messy Books Pvt Ltd", - "items": [ - {"item_code": "Anything and Everything Item", "rate": 8000.0, "qty": 1.0} - ], + "item_code": "Anything and Everything Item", + "rate": 8000.0, + "qty": 1.0, }, { "supplier": "Complex Terms LLP", - "items": [ - {"item_code": "Anything and Everything Item", "rate": 10000.0, "qty": 1.0} - ], + "item_code": "Anything and Everything Item", + "rate": 10000.0, + "qty": 1.0, }, { "supplier": "Honest Consultant", - "items": [ - {"item_code": "Anything and Everything Item", "rate": 10000.0, "qty": 1.0} - ], + "item_code": "Anything and Everything Item", + "rate": 10000.0, + "qty": 1.0, }, { "supplier": "Honest Consultant", - "items": [ - {"item_code": "Anything and Everything Item", "rate": 90000.0, "qty": 1.0} - ], + "item_code": "Anything and Everything Item", + "rate": 90000.0, + "qty": 1.0, }, { "supplier": "Honest Consultant", - "items": [ - {"item_code": "Anything and Everything Item", "rate": 80000.0, "qty": 1.0} - ], + "item_code": "Anything and Everything Item", + "rate": 80000.0, + "qty": 1.0, }, { "supplier": "Eco Stationery", - "items": [ - {"item_code": "Anything and Everything Item", "rate": 6000.0, "qty": 1.0} - ], + "item_code": "Anything and Everything Item", + "rate": 6000.0, + "qty": 1.0, }, { "supplier": "Defective Goods LLP", - "items": [ - {"item_code": "Anything and Everything Item", "rate": 11000.0, "qty": 1.0} - ], + "item_code": "Anything and Everything Item", + "rate": 11000.0, + "qty": 1.0, }, { "supplier": "Always Non-Compliant", - "items": [ - {"item_code": "Anything and Everything Item", "rate": 11000.0, "qty": 1.0} - ], + "item_code": "Anything and Everything Item", + "rate": 11000.0, + "qty": 1.0, }, { "supplier": "Common Party Pvt Ltd", - "items": [ - {"item_code": "Anything and Everything Item", "rate": 27000.0, "qty": 1.0} - ], + "item_code": "Anything and Everything Item", + "rate": 27000.0, + "qty": 1.0, }, { "supplier": "Common Party Pvt Ltd", - "items": [ - {"item_code": "Anything and Everything Item", "rate": 26000.0, "qty": 1.0} - ], + "item_code": "Anything and Everything Item", + "rate": 26000.0, + "qty": 1.0, }, ] +def change_settings(settings): + def decorator(func): + def wrapper(self, *args, **kwargs): + with _change_settings( + CONFIGURATION_DOCTYPE, self.payment_configuration_setting, settings + ): + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + class TestPaymentsProcessor(IntegrationTestCase): @classmethod def setUpClass(cls): - return super().setUpClass() + super().setUpClass() + cls.payment_configuration_setting = frappe.get_all( + CONFIGURATION_DOCTYPE, + fields="*", + filters={"company": TEST_COMPANY, "disabled": 0}, + )[0] + + def tearDown(self): + frappe.db.rollback() + + def get_report_data(self): + return execute(frappe._dict({"company": TEST_COMPANY}))[1] + + @change_settings({"claim_early_payment_discount": 1}) + def test_claim_early_discount(self): + for invoice in DISCOUNT_INVOICES: + make_purchase_invoice(**invoice) + + report_data = self.get_report_data() + for index, row in enumerate(report_data): + self.assertPartialDict(EXPECTED_DISCOUNTED_INVOICE_DATA[index], row) + + def test_blocked_supplier_invoices(self): + for invoice in BLOCKED_SUPPLIER_INVOICES: + make_purchase_invoice(**invoice) + + report_data = self.get_report_data() + + for index, row in enumerate(report_data): + self.assertPartialDict(EXPECTED_BLOCKED_SUPPLIER_INVOICE_DATA[index], row) + + def assertPartialDict(self, d1, d2): + self.assertIsInstance(d1, dict, "First argument is not a dictionary") + self.assertIsInstance(d2, dict, "Second argument is not a dictionary") + + if d1 != d2: + for key in d1: + if d1[key] != d2[key]: + standardMsg = f"{key}: {d1[key]} != {d2[key]}" + self.fail(standardMsg) + + +def make_purchase_invoice(**args): + pi = frappe.new_doc("Purchase Invoice") + args = frappe._dict(args) + + pi.company = TEST_COMPANY + pi.posting_date = args.posting_date or today() + pi.due_date = args.due_date or today() + pi.supplier = args.supplier + pi.currency = args.currency or "INR" + pi.conversion_rate = args.conversion_rate or 1 + pi.is_return = args.is_return + pi.return_against = args.return_against + + pi.append( + "items", + { + "item_code": args.item_code, + "qty": args.qty if args.qty is not None else 1, + "rate": args.rate or 50, + }, + ) - def create_test_records(self): - pass + if not args.do_not_save: + pi.insert() + if not args.do_not_submit: + pi.submit() - def test_invoice(self): - pass + return pi diff --git a/payments_processor/tests/test_records.json b/payments_processor/tests/test_records.json index 7f4116b..2f20864 100644 --- a/payments_processor/tests/test_records.json +++ b/payments_processor/tests/test_records.json @@ -106,5 +106,37 @@ "supplier_name": "Messy Books Pvt Ltd", "supplier_type": "Company" } + ], + "Account": [ + { + "account_name": "Test Account", + "parent_account": "Bank Accounts - _TC", + "company": "_Test Company" + } + ], + "Bank": [ + { + "bank_name": "Test Bank" + } + ], + "Bank Account": [ + { + "account_name": "Test Bank Account", + "bank": "Test Bank", + "company": "_Test Company" + } + ], + "Payments Processor Configuration": [ + { + "bank_account": "Test Bank Account - Test Bank", + "company": "_Test Company", + "automate_on_monday": 1, + "automate_on_tuesday": 1, + "automate_on_wednesday": 1, + "automate_on_thursday": 1, + "automate_on_friday": 1, + "automate_on_saturday": 1, + "automate_on_sunday": 1 + } ] } \ No newline at end of file diff --git a/payments_processor/tests/utils.py b/payments_processor/tests/utils.py new file mode 100644 index 0000000..f8aadc5 --- /dev/null +++ b/payments_processor/tests/utils.py @@ -0,0 +1,27 @@ +from contextlib import contextmanager # noqa: I001 + +import frappe +from frappe.tests import IntegrationTestCase + + +@IntegrationTestCase.registerAs(staticmethod) +@contextmanager +def change_settings(doctype, doc, settings_dict=None, /, commit=False, **settings): + doc = frappe.get_doc(doctype, doc) + if settings_dict is None: + settings_dict = settings + + previous_settings = {key: getattr(doc, key) for key in settings_dict} + + doc.update(settings_dict) + doc.save(ignore_permissions=True) + if commit: + frappe.db.commit() + + yield + + doc.update(previous_settings) + doc.save(ignore_permissions=True) + + if commit: + frappe.db.commit() From 4607bee60564341db20c641d9f593f32a0f04e34 Mon Sep 17 00:00:00 2001 From: priyanshshah2442 Date: Mon, 10 Mar 2025 16:16:46 +0530 Subject: [PATCH 05/14] fix: minor change --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7a16e5a..02ff31f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,8 +10,8 @@ repos: files: "payments_processor.*" exclude: ".*json$|.*txt$|.*csv|.*md" - id: check-yaml - # - id: no-commit-to-branch - # args: ["--branch", "develop"] + - id: no-commit-to-branch + args: ["--branch", "version-15"] - id: check-merge-conflict - id: check-ast From c2827285e04a165ba2b4ae9fb0069ef2dcfc44ed Mon Sep 17 00:00:00 2001 From: priyanshshah2442 Date: Tue, 11 Mar 2025 12:20:37 +0530 Subject: [PATCH 06/14] fix: some more test cases --- .../payments_processor/utils/automation.py | 19 +- .../utils/test_automation.py | 274 +++++++++++++++++- payments_processor/tests/test_records.json | 7 + 3 files changed, 289 insertions(+), 11 deletions(-) diff --git a/payments_processor/payments_processor/utils/automation.py b/payments_processor/payments_processor/utils/automation.py index 2b9a21f..e18c910 100644 --- a/payments_processor/payments_processor/utils/automation.py +++ b/payments_processor/payments_processor/utils/automation.py @@ -8,7 +8,13 @@ from frappe import _ from frappe.core.doctype.role.role import get_info_based_on_role from frappe.email.doctype.email_template.email_template import get_email_template -from frappe.utils import add_days, get_timedelta, getdate, now_datetime +from frappe.utils import ( + add_days, + get_link_to_form, + get_timedelta, + getdate, + now_datetime, +) from erpnext import get_default_cost_center from erpnext.accounts.utils import get_balance_on @@ -131,6 +137,7 @@ def create_payments(self): ): def get_invoice_group(invoice_group): + print("setting", self.setting.group_payments_by_supplier) if self.setting.group_payments_by_supplier: return [invoice_group] @@ -157,6 +164,7 @@ def update_payment_info(invoice_group, pe): update_payment_info(invoice_group, pe) except Exception: + print("in except") self.handle_pe_creation_failed(supplier_name) frappe.log_error( title=_( @@ -486,6 +494,13 @@ def process_auto_submit(self): def create_payment_entry(self, supplier_name, invoice_list): # TODO: how do we handle failure of payment entry + if not self.paid_from: + frappe.throw( + _("Please set Company Account in Bank Account: {0}").format( + get_link_to_form("Bank Account", self.setting.bank_account) + ) + ) + pe = frappe.new_doc("Payment Entry") paid_amount = 0 @@ -545,7 +560,7 @@ def create_payment_entry(self, supplier_name, invoice_list): { "account": self.discount_account, "cost_center": default_cost_center, # TODO: could be different for each invoice (for now we are using Default Cost Center as specified in Company) - "amount": total_discount, + "amount": -total_discount, }, ) diff --git a/payments_processor/payments_processor/utils/test_automation.py b/payments_processor/payments_processor/utils/test_automation.py index aac65c9..2e51d37 100644 --- a/payments_processor/payments_processor/utils/test_automation.py +++ b/payments_processor/payments_processor/utils/test_automation.py @@ -6,6 +6,7 @@ from payments_processor.payments_processor.report.upcoming_invoice_payment.upcoming_invoice_payment import ( execute, ) +from payments_processor.payments_processor.utils.automation import PaymentsProcessor from payments_processor.tests.utils import change_settings as _change_settings TEST_COMPANY = "_Test Company" @@ -26,9 +27,6 @@ "hold_comment": None, "amount_to_pay": 9900.0, "auto_generate": 1, - "auto_submit": 0, - "reason": "Payment submission threshold exceeded", - "reason_code": "1021", } ] @@ -52,6 +50,217 @@ } ] +GROUP_SUPPLIER_INVOICES = [ + { + "supplier": "Honest Consultant", + "item_code": "Anything and Everything Item", + "rate": 10000.0, + "qty": 1.0, + }, + { + "supplier": "Honest Consultant", + "item_code": "Anything and Everything Item", + "rate": 90000.0, + "qty": 1.0, + }, + { + "supplier": "Honest Consultant", + "item_code": "Anything and Everything Item", + "rate": 80000.0, + "qty": 1.0, + }, +] + +EXPECTED_ENTRY_GRP_ENABLED = { + "docstatus": 0, + "payment_type": "Pay", + "company": "_Test Company", + "party_type": "Supplier", + "party": "Honest Consultant", + "party_name": "Honest Consultant", + "bank_account": "Test Bank Account - Test Bank", + "party_bank_account": "Honest Consultant - Customer First Bank", + "paid_from": "Test Company Account - _TC", + "paid_from_account_currency": "INR", + "paid_to": "Creditors - _TC", + "paid_to_account_currency": "INR", + "paid_amount": 180000.0, + "paid_amount_after_tax": 180000.0, + "source_exchange_rate": 1.0, + "base_paid_amount": 180000.0, + "base_paid_amount_after_tax": 180000.0, + "received_amount": 180000.0, + "received_amount_after_tax": 180000.0, + "target_exchange_rate": 1.0, + "base_received_amount": 180000.0, + "base_received_amount_after_tax": 180000.0, + "total_allocated_amount": 180000.0, + "base_total_allocated_amount": 180000.0, + "unallocated_amount": 0.0, + "difference_amount": 0.0, + "base_total_taxes_and_charges": 0.0, + "total_taxes_and_charges": 0.0, + "bank": "Test Bank", + "in_words": "INR One Lakh, Eighty Thousand only.", + "references": [ + { + "reference_doctype": "Purchase Invoice", + "total_amount": 10000.0, + "outstanding_amount": 10000.0, + "allocated_amount": 10000.0, + "exchange_rate": 1.0, + "exchange_gain_loss": 0.0, + "account": "Creditors - _TC", + }, + { + "reference_doctype": "Purchase Invoice", + "total_amount": 90000.0, + "outstanding_amount": 90000.0, + "allocated_amount": 90000.0, + "exchange_rate": 1.0, + "account": "Creditors - _TC", + }, + { + "reference_doctype": "Purchase Invoice", + "total_amount": 80000.0, + "outstanding_amount": 80000.0, + "allocated_amount": 80000.0, + "exchange_rate": 1.0, + "exchange_gain_loss": 0.0, + "account": "Creditors - _TC", + }, + ], +} + +EXPECTED_ENTRY_GRP_DISABLED = [ + { + "docstatus": 0, + "payment_type": "Pay", + "company": "_Test Company", + "party_type": "Supplier", + "party": "Honest Consultant", + "party_name": "Honest Consultant", + "bank_account": "Test Bank Account - Test Bank", + "party_bank_account": "Honest Consultant - Customer First Bank", + "paid_from": "Test Company Account - _TC", + "paid_from_account_currency": "INR", + "paid_to": "Creditors - _TC", + "paid_to_account_currency": "INR", + "paid_amount": 80000.0, + "paid_amount_after_tax": 80000.0, + "source_exchange_rate": 1.0, + "base_paid_amount": 80000.0, + "base_paid_amount_after_tax": 80000.0, + "received_amount": 80000.0, + "received_amount_after_tax": 80000.0, + "target_exchange_rate": 1.0, + "base_received_amount": 80000.0, + "base_received_amount_after_tax": 80000.0, + "total_allocated_amount": 80000.0, + "base_total_allocated_amount": 80000.0, + "unallocated_amount": 0.0, + "difference_amount": 0.0, + "base_total_taxes_and_charges": 0.0, + "total_taxes_and_charges": 0.0, + "bank": "Test Bank", + "in_words": "INR Eighty Thousand only.", + "references": [ + { + "reference_doctype": "Purchase Invoice", + "total_amount": 80000.0, + "outstanding_amount": 80000.0, + "allocated_amount": 80000.0, + "exchange_rate": 1.0, + "account": "Creditors - _TC", + } + ], + }, + { + "docstatus": 0, + "payment_type": "Pay", + "company": "_Test Company", + "party_type": "Supplier", + "party": "Honest Consultant", + "party_name": "Honest Consultant", + "bank_account": "Test Bank Account - Test Bank", + "party_bank_account": "Honest Consultant - Customer First Bank", + "paid_from": "Test Company Account - _TC", + "paid_from_account_currency": "INR", + "paid_to": "Creditors - _TC", + "paid_to_account_currency": "INR", + "paid_amount": 90000.0, + "paid_amount_after_tax": 90000.0, + "source_exchange_rate": 1.0, + "base_paid_amount": 90000.0, + "base_paid_amount_after_tax": 90000.0, + "received_amount": 90000.0, + "received_amount_after_tax": 90000.0, + "target_exchange_rate": 1.0, + "base_received_amount": 90000.0, + "base_received_amount_after_tax": 90000.0, + "total_allocated_amount": 90000.0, + "base_total_allocated_amount": 90000.0, + "unallocated_amount": 0.0, + "difference_amount": 0.0, + "base_total_taxes_and_charges": 0.0, + "total_taxes_and_charges": 0.0, + "bank": "Test Bank", + "in_words": "INR Ninety Thousand only.", + "references": [ + { + "reference_doctype": "Purchase Invoice", + "total_amount": 90000.0, + "outstanding_amount": 90000.0, + "allocated_amount": 90000.0, + "exchange_rate": 1.0, + "account": "Creditors - _TC", + } + ], + }, + { + "docstatus": 0, + "payment_type": "Pay", + "company": "_Test Company", + "party_type": "Supplier", + "party": "Honest Consultant", + "party_name": "Honest Consultant", + "bank_account": "Test Bank Account - Test Bank", + "party_bank_account": "Honest Consultant - Customer First Bank", + "paid_from": "Test Company Account - _TC", + "paid_from_account_currency": "INR", + "paid_to": "Creditors - _TC", + "paid_to_account_currency": "INR", + "paid_amount": 10000.0, + "paid_amount_after_tax": 10000.0, + "source_exchange_rate": 1.0, + "base_paid_amount": 10000.0, + "base_paid_amount_after_tax": 10000.0, + "received_amount": 10000.0, + "received_amount_after_tax": 10000.0, + "target_exchange_rate": 1.0, + "base_received_amount": 10000.0, + "base_received_amount_after_tax": 10000.0, + "total_allocated_amount": 10000.0, + "base_total_allocated_amount": 10000.0, + "unallocated_amount": 0.0, + "difference_amount": 0.0, + "base_total_taxes_and_charges": 0.0, + "total_taxes_and_charges": 0.0, + "bank": "Test Bank", + "in_words": "INR Ten Thousand only.", + "references": [ + { + "reference_doctype": "Purchase Invoice", + "total_amount": 10000.0, + "outstanding_amount": 10000.0, + "allocated_amount": 10000.0, + "exchange_rate": 1.0, + "account": "Creditors - _TC", + } + ], + }, +] + INVOICES = [ { "supplier": "Messy Books Pvt Ltd", @@ -95,6 +304,7 @@ "rate": 11000.0, "qty": 1.0, }, + # done { "supplier": "Always Non-Compliant", "item_code": "Anything and Everything Item", @@ -147,34 +357,80 @@ def get_report_data(self): @change_settings({"claim_early_payment_discount": 1}) def test_claim_early_discount(self): - for invoice in DISCOUNT_INVOICES: - make_purchase_invoice(**invoice) + make_purchase_invoices(DISCOUNT_INVOICES) report_data = self.get_report_data() for index, row in enumerate(report_data): self.assertPartialDict(EXPECTED_DISCOUNTED_INVOICE_DATA[index], row) def test_blocked_supplier_invoices(self): - for invoice in BLOCKED_SUPPLIER_INVOICES: - make_purchase_invoice(**invoice) + make_purchase_invoices(BLOCKED_SUPPLIER_INVOICES) report_data = self.get_report_data() for index, row in enumerate(report_data): self.assertPartialDict(EXPECTED_BLOCKED_SUPPLIER_INVOICE_DATA[index], row) + @change_settings({"group_payments_by_supplier": 1}) + def test_group_payments_by_supplier_enabled(self): + payment_entry = self.process_and_fetch_payment_entry()[0] + + self.assertPartialDict( + EXPECTED_ENTRY_GRP_ENABLED, + frappe.get_doc("Payment Entry", payment_entry).as_dict(), + ) + + @change_settings({"group_payments_by_supplier": 0}) + def test_group_payments_by_supplier_disabled(self): + payment_entries = self.process_and_fetch_payment_entry() + + for index, entry in enumerate(payment_entries): + self.assertPartialDict( + EXPECTED_ENTRY_GRP_DISABLED[index], + frappe.get_doc("Payment Entry", entry).as_dict(), + ) + + def process_and_fetch_payment_entry(self): + parties = {invoice["supplier"] for invoice in GROUP_SUPPLIER_INVOICES} + + make_purchase_invoices(GROUP_SUPPLIER_INVOICES) + + payments_processor = PaymentsProcessor(self.payment_configuration_setting.name) + payments_processor.process_invoices() + payments_processor.create_payments() + + return frappe.get_all( + "Payment Entry", + filters={ + "company": TEST_COMPANY, + "payment_type": "Pay", + "party_type": "Supplier", + "party": ["in", list(parties)], + }, + ) + def assertPartialDict(self, d1, d2): self.assertIsInstance(d1, dict, "First argument is not a dictionary") self.assertIsInstance(d2, dict, "Second argument is not a dictionary") if d1 != d2: for key in d1: - if d1[key] != d2[key]: + if isinstance(d1[key], list): + for i, item in enumerate(d1[key]): + self.assertPartialDict(item, d2[key][i]) + elif isinstance(d1[key], dict): + self.assertPartialDict(d1[key], d2[key]) + elif d1[key] != d2[key]: standardMsg = f"{key}: {d1[key]} != {d2[key]}" self.fail(standardMsg) -def make_purchase_invoice(**args): +def make_purchase_invoices(invoices): + for invoice in invoices: + _make_purchase_invoice(**invoice) + + +def _make_purchase_invoice(**args): pi = frappe.new_doc("Purchase Invoice") args = frappe._dict(args) diff --git a/payments_processor/tests/test_records.json b/payments_processor/tests/test_records.json index 2f20864..480e3cb 100644 --- a/payments_processor/tests/test_records.json +++ b/payments_processor/tests/test_records.json @@ -112,6 +112,13 @@ "account_name": "Test Account", "parent_account": "Bank Accounts - _TC", "company": "_Test Company" + }, + { + "account_name": "Test Company Account", + "parent_account": "Accounts Payable - _TC", + "company": "_Test Company", + "is_group": 0, + "account_type": "Bank" } ], "Bank": [ From d02464b83f03fb61bbe00d092dc7827d7eaa426d Mon Sep 17 00:00:00 2001 From: priyanshshah2442 Date: Tue, 11 Mar 2025 12:34:44 +0530 Subject: [PATCH 07/14] fix: minor refactor --- .../utils/test_automation.py | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/payments_processor/payments_processor/utils/test_automation.py b/payments_processor/payments_processor/utils/test_automation.py index 2e51d37..29fac0a 100644 --- a/payments_processor/payments_processor/utils/test_automation.py +++ b/payments_processor/payments_processor/utils/test_automation.py @@ -407,22 +407,38 @@ def process_and_fetch_payment_entry(self): "party_type": "Supplier", "party": ["in", list(parties)], }, + order_by="creation desc", ) def assertPartialDict(self, d1, d2): self.assertIsInstance(d1, dict, "First argument is not a dictionary") self.assertIsInstance(d2, dict, "Second argument is not a dictionary") - if d1 != d2: - for key in d1: - if isinstance(d1[key], list): - for i, item in enumerate(d1[key]): - self.assertPartialDict(item, d2[key][i]) - elif isinstance(d1[key], dict): - self.assertPartialDict(d1[key], d2[key]) - elif d1[key] != d2[key]: - standardMsg = f"{key}: {d1[key]} != {d2[key]}" - self.fail(standardMsg) + for key, value in d1.items(): + if isinstance(value, list): + self.assertIsInstance( + d2[key], + list, + f"Key '{key}' is not a list in second dictionary", + ) + self.assertLessEqual( + len(value), + len(d2[key]), + f"List at key '{key}' is shorter than expected", + ) + + for i, item in enumerate(value): + self.assertPartialDict(item, d2[key][i]) + + elif isinstance(d1[key], dict): + self.assertIsInstance( + d2[key], dict, f"Key '{key}' is not a dict in second dictionary" + ) + self.assertPartialDict(d1[key], d2[key]) + else: + self.assertEqual( + value, d2[key], f"Mismatch at key '{key}': {value} != {d2[key]}" + ) def make_purchase_invoices(invoices): From 0558fcc6a5fac805f16e416fc4c513a7bcf9ca16 Mon Sep 17 00:00:00 2001 From: priyanshshah2442 Date: Tue, 11 Mar 2025 17:50:18 +0530 Subject: [PATCH 08/14] fix: changes as per review --- .pre-commit-config.yaml | 76 ++++++++------- payments_processor/hooks.py | 1 - payments_processor/install.py | 1 - .../payments_processor_configuration.py | 4 +- .../payments_processor/utils/automation.py | 92 ++++++++----------- .../utils/test_automation.py | 7 +- payments_processor/setup.py | 1 - payments_processor/tests/utils.py | 6 +- pyproject.toml | 27 ++---- 9 files changed, 98 insertions(+), 117 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 02ff31f..962fd82 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,29 +1,24 @@ -exclude: "node_modules|.git" +exclude: 'node_modules|.git' default_stages: [commit] fail_fast: false + repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.3.0 hooks: - id: trailing-whitespace files: "payments_processor.*" exclude: ".*json$|.*txt$|.*csv|.*md" - id: check-yaml - id: no-commit-to-branch - args: ["--branch", "version-15"] + args: ['--branch', 'version-15'] - id: check-merge-conflict - id: check-ast - - - repo: https://github.com/psf/black - rev: 24.1.1 - hooks: - - id: black - - - repo: https://github.com/PyCQA/isort - rev: 5.13.2 - hooks: - - id: isort + - id: check-json + - id: check-toml + - id: check-yaml + - id: debug-statements - repo: https://github.com/pre-commit/mirrors-prettier rev: v2.7.1 @@ -32,35 +27,48 @@ repos: types_or: [javascript, vue, scss] # Ignore any files that might contain jinja / bundles exclude: | - (?x)^( - payments_processor/public/dist/.*| - cypress/.*| - .*node_modules.*| - .*boilerplate.* - )$ + (?x)^( + payments_processor/public/dist/.*| + cypress/.*| + .*node_modules.*| + .*boilerplate.*| + payments_processor/public/js/controllers/.*| + payments_processor/templates/pages/order.js| + payments_processor/templates/includes/.* + )$ - repo: https://github.com/pre-commit/mirrors-eslint rev: v8.44.0 hooks: - id: eslint types_or: [javascript] - args: ["--quiet"] + args: ['--quiet'] # Ignore any files that might contain jinja / bundles exclude: | - (?x)^( - payments_processor/public/dist/.*| - cypress/.*| - .*node_modules.*| - .*boilerplate.* - )$ + (?x)^( + payments_processor/public/dist/.*| + cypress/.*| + .*node_modules.*| + .*boilerplate.*| + payments_processor/public/js/controllers/.*| + payments_processor/templates/pages/order.js| + payments_processor/templates/includes/.* + )$ + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.2.0 + hooks: + - id: ruff + name: "Run ruff import sorter" + args: ["--select=I", "--fix"] + + - id: ruff + name: "Run ruff linter" - # - repo: https://github.com/PyCQA/flake8 - # rev: 7.0.0 - # hooks: - # - id: flake8 - # additional_dependencies: [flake8-isort, flake8-bugbear] + - id: ruff-format + name: "Run ruff formatter" ci: - autoupdate_schedule: weekly - skip: [] - submodules: false + autoupdate_schedule: weekly + skip: [] + submodules: false diff --git a/payments_processor/hooks.py b/payments_processor/hooks.py index 935efca..f322e63 100644 --- a/payments_processor/hooks.py +++ b/payments_processor/hooks.py @@ -10,7 +10,6 @@ before_tests = "payments_processor.tests.before_tests" -# TODO: Make this configurable scheduler_events = { "all": [ "payments_processor.payments_processor.utils.automation.autocreate_payment_entry" diff --git a/payments_processor/install.py b/payments_processor/install.py index e1cb9a1..00df217 100644 --- a/payments_processor/install.py +++ b/payments_processor/install.py @@ -1,5 +1,4 @@ import click - import frappe from payments_processor.constants import BUG_REPORT_URL diff --git a/payments_processor/payments_processor/doctype/payments_processor_configuration/payments_processor_configuration.py b/payments_processor/payments_processor/doctype/payments_processor_configuration/payments_processor_configuration.py index 88e1257..0460e69 100644 --- a/payments_processor/payments_processor/doctype/payments_processor_configuration/payments_processor_configuration.py +++ b/payments_processor/payments_processor/doctype/payments_processor_configuration/payments_processor_configuration.py @@ -1,11 +1,11 @@ # Copyright (c) 2024, Resilient Tech and contributors # For license information, please see license.txt -import frappe # noqa: I001 +import frappe +from erpnext import get_default_cost_center from frappe import _ from frappe.model.document import Document from frappe.utils import get_link_to_form -from erpnext import get_default_cost_center # Auto Payment Setting # Payouts not required diff --git a/payments_processor/payments_processor/utils/automation.py b/payments_processor/payments_processor/utils/automation.py index e18c910..f38c212 100644 --- a/payments_processor/payments_processor/utils/automation.py +++ b/payments_processor/payments_processor/utils/automation.py @@ -1,10 +1,10 @@ -import calendar # noqa: I001 +import calendar from collections import defaultdict from functools import cached_property -from pypika import Order - import frappe +from erpnext import get_default_cost_center +from erpnext.accounts.utils import get_balance_on from frappe import _ from frappe.core.doctype.role.role import get_info_based_on_role from frappe.email.doctype.email_template.email_template import get_email_template @@ -15,8 +15,7 @@ getdate, now_datetime, ) -from erpnext import get_default_cost_center -from erpnext.accounts.utils import get_balance_on +from pypika import Order from payments_processor.constants import CONFIGURATION_DOCTYPE from payments_processor.payments_processor.constants.roles import ROLE_PROFILE @@ -137,7 +136,6 @@ def create_payments(self): ): def get_invoice_group(invoice_group): - print("setting", self.setting.group_payments_by_supplier) if self.setting.group_payments_by_supplier: return [invoice_group] @@ -164,7 +162,6 @@ def update_payment_info(invoice_group, pe): update_payment_info(invoice_group, pe) except Exception: - print("in except") self.handle_pe_creation_failed(supplier_name) frappe.log_error( title=_( @@ -385,24 +382,6 @@ def process_auto_generate(self): invalid = self.processed_invoices.setdefault("invalid", frappe._dict()) valid = self.processed_invoices.setdefault("valid", frappe._dict()) - supplier_checks = [ - lambda sup, _: self.is_supplier_disabled(sup), - lambda sup, _: self.is_supplier_blocked(sup), - lambda sup, _: self.is_auto_generate_disabled(sup), - lambda _, inv: self.payment_entry_exists(inv), - lambda sup, inv: self.is_payment_exceeding_supplier_outstanding(sup, inv), - lambda _, inv: ( - self.is_auto_generate_threshold_exceeded(inv.amount_to_pay) - if not self.setting.group_payments_by_supplier - else None - ), - ] - - invoice_checks = [ - self.is_invoice_blocked, - self.exclude_foreign_currency_invoices, - ] - for invoice in self.invoices.values(): supplier = self.suppliers.get(invoice.supplier) invoice.amount_to_pay = ( @@ -416,49 +395,55 @@ def process_auto_generate(self): ) continue - continue_outer = False - for check in supplier_checks: - if msg := check(supplier, invoice): - invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) - continue_outer = True - break # No need to check further if already invalid + if msg := self.is_supplier_disabled(supplier): + invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) + continue + + if msg := self.is_supplier_blocked(supplier): + invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) + continue + + if msg := self.is_auto_generate_disabled(supplier): + invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) + continue + + # run before outstanding check (for better error message) + # since outstanding amount is adjusted based on draft PEs + if msg := self.payment_entry_exists(invoice): + invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) + continue + + if msg := self.is_payment_exceeding_supplier_outstanding(supplier, invoice): + invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) + continue - if continue_outer: + if not self.setting.group_payments_by_supplier and ( + msg := self.is_auto_generate_threshold_exceeded(invoice.amount_to_pay) + ): + invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) continue # invoice validations - continue_outer = False - for check in invoice_checks: - if msg := check(invoice): - invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) - continue_outer = True - break + if msg := self.is_invoice_blocked(invoice): + invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) + continue - if continue_outer: + if msg := self.exclude_foreign_currency_invoices(invoice): + invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) continue - for fn in frappe.get_hooks("filter_auto_generate_payments"): + functions = frappe.get_hooks("filter_auto_generate_payments") + for fn in functions: if msg := frappe.call(fn, supplier=supplier, invoice=invoice): invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) break + else: self.supplier_paid_amount[invoice.supplier] += invoice.amount_to_pay + invoice.auto_generate = 1 valid.setdefault(invoice.supplier, []).append(invoice) - if not self.setting.group_payments_by_supplier: - return - - # Grouped PE - for supplier_name, paid_amount in self.supplier_paid_amount.items(): - supplier = self.suppliers[supplier_name] - - if msg := self.is_auto_generate_threshold_exceeded(paid_amount): - for invoice in valid[supplier_name]: - invoice.update({**msg, "auto_generate": 0}) - - invalid.setdefault(supplier_name, []).extend(valid.pop(supplier_name)) - def process_auto_submit(self): if not self.setting.auto_submit_entries: return @@ -493,7 +478,6 @@ def process_auto_submit(self): invoice.update({**msg, "auto_submit": 0}) def create_payment_entry(self, supplier_name, invoice_list): - # TODO: how do we handle failure of payment entry if not self.paid_from: frappe.throw( _("Please set Company Account in Bank Account: {0}").format( diff --git a/payments_processor/payments_processor/utils/test_automation.py b/payments_processor/payments_processor/utils/test_automation.py index 29fac0a..a27e6a1 100644 --- a/payments_processor/payments_processor/utils/test_automation.py +++ b/payments_processor/payments_processor/utils/test_automation.py @@ -1,4 +1,4 @@ -import frappe # noqa: I001 +import frappe from frappe.tests import IntegrationTestCase from frappe.utils import add_days, today @@ -345,9 +345,8 @@ def setUpClass(cls): super().setUpClass() cls.payment_configuration_setting = frappe.get_all( CONFIGURATION_DOCTYPE, - fields="*", filters={"company": TEST_COMPANY, "disabled": 0}, - )[0] + )[0].name def tearDown(self): frappe.db.rollback() @@ -395,7 +394,7 @@ def process_and_fetch_payment_entry(self): make_purchase_invoices(GROUP_SUPPLIER_INVOICES) - payments_processor = PaymentsProcessor(self.payment_configuration_setting.name) + payments_processor = PaymentsProcessor(self.payment_configuration_setting) payments_processor.process_invoices() payments_processor.create_payments() diff --git a/payments_processor/setup.py b/payments_processor/setup.py index 80eec61..ceae909 100644 --- a/payments_processor/setup.py +++ b/payments_processor/setup.py @@ -1,5 +1,4 @@ import click - import frappe from frappe.custom.doctype.custom_field.custom_field import ( create_custom_fields as make_custom_fields, diff --git a/payments_processor/tests/utils.py b/payments_processor/tests/utils.py index f8aadc5..cfdd878 100644 --- a/payments_processor/tests/utils.py +++ b/payments_processor/tests/utils.py @@ -1,4 +1,4 @@ -from contextlib import contextmanager # noqa: I001 +from contextlib import contextmanager import frappe from frappe.tests import IntegrationTestCase @@ -6,8 +6,8 @@ @IntegrationTestCase.registerAs(staticmethod) @contextmanager -def change_settings(doctype, doc, settings_dict=None, /, commit=False, **settings): - doc = frappe.get_doc(doctype, doc) +def change_settings(doctype, docname, settings_dict=None, /, commit=False, **settings): + doc = frappe.get_doc(doctype, docname) if settings_dict is None: settings_dict = settings diff --git a/pyproject.toml b/pyproject.toml index c9a190e..591fba8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,12 +9,21 @@ dependencies = [ # "frappe~=15.0.0" # Installed and managed by bench. ] + [build-system] requires = ["flit_core >=3.4,<4"] build-backend = "flit_core.buildapi" [tool.ruff.lint] -select = ["F", "E", "W", "I", "UP", "B", "RUF"] +select = [ + "F", + "E", + "W", + "I", + "UP", + "B", + "RUF", +] ignore = [ "E501", # line too long @@ -37,19 +46,3 @@ docstring-code-format = true [tool.bench.frappe-dependencies] frappe = ">=15.0.0,<16.0.0" erpnext = ">=15.0.0,<16.0.0" - - -[tool.isort] -profile = "black" -known_frappe = "frappe" -known_erpnext = "erpnext" -no_lines_before = ["ERPNEXT"] -sections = [ - "FUTURE", - "STDLIB", - "THIRDPARTY", - "FRAPPE", - "ERPNEXT", - "FIRSTPARTY", - "LOCALFOLDER", -] From ac3b4609e606912e74b5280e2ed337269237edc0 Mon Sep 17 00:00:00 2001 From: priyanshshah2442 Date: Tue, 11 Mar 2025 18:09:24 +0530 Subject: [PATCH 09/14] fix: server tests setup --- .github/helper/install.sh | 68 +++++++++++++ .github/helper/site_config.json | 17 ++++ .github/workflows/server-tests.yml | 147 +++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 .github/helper/install.sh create mode 100644 .github/helper/site_config.json create mode 100644 .github/workflows/server-tests.yml diff --git a/.github/helper/install.sh b/.github/helper/install.sh new file mode 100644 index 0000000..c1d5d42 --- /dev/null +++ b/.github/helper/install.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +set -e + +# Check for merge conflicts before proceeding +python -m compileall -f "${GITHUB_WORKSPACE}" +if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}" + then echo "Found merge conflicts" + exit 1 +fi + +cd ~ || exit + +echo "Setting Up System Dependencies..." + +sudo apt update + +sudo apt remove mysql-server mysql-client +sudo apt install libcups2-dev redis-server mariadb-client + +install_whktml() { + wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb + sudo apt install /tmp/wkhtmltox.deb +} +install_whktml & +wkpid=$! + +pip install frappe-bench + +git clone "https://github.com/frappe/frappe" --branch "$BRANCH_TO_CLONE" --depth 1 +bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench + +mkdir ~/frappe-bench/sites/test_site + +cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config.json" ~/frappe-bench/sites/test_site/ + + +mariadb --host 127.0.0.1 --port 3306 -u root -ptravis -e " +SET GLOBAL character_set_server = 'utf8mb4'; +SET GLOBAL collation_server = 'utf8mb4_unicode_ci'; + +CREATE USER 'test_resilient'@'localhost' IDENTIFIED BY 'test_resilient'; +CREATE DATABASE test_resilient; +GRANT ALL PRIVILEGES ON \`test_resilient\`.* TO 'test_resilient'@'localhost'; + +FLUSH PRIVILEGES; +" + +cd ~/frappe-bench || exit + +sed -i 's/watch:/# watch:/g' Procfile +sed -i 's/schedule:/# schedule:/g' Procfile +sed -i 's/socketio:/# socketio:/g' Procfile +sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile + +bench get-app erpnext --branch "$BRANCH_TO_CLONE" --resolve-deps +bench get-app payments_processor "${GITHUB_WORKSPACE}" +bench setup requirements --dev + +wait $wkpid + +bench use test_site +bench start & +bench reinstall --yes + +bench --verbose install-app payments_processor +bench --site test_site add-to-hosts + diff --git a/.github/helper/site_config.json b/.github/helper/site_config.json new file mode 100644 index 0000000..4487e09 --- /dev/null +++ b/.github/helper/site_config.json @@ -0,0 +1,17 @@ +{ + "db_host": "127.0.0.1", + "db_port": 3306, + "db_name": "test_frappe", + "db_password": "test_frappe", + "db_type": "mariadb", + "auto_email_id": "test@example.com", + "mail_server": "smtp.example.com", + "mail_login": "test@example.com", + "mail_password": "test", + "admin_password": "admin", + "root_login": "root", + "root_password": "travis", + "host_name": "http://test_site:8000", + "install_apps": ["erpnext"], + "throttle_user_limit": 100 + } \ No newline at end of file diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml new file mode 100644 index 0000000..0bd7d83 --- /dev/null +++ b/.github/workflows/server-tests.yml @@ -0,0 +1,147 @@ +name: Server Tests + +on: + workflow_call: + inputs: + base_ref: + type: string + + app_name: + type: string + + secrets: + codecov_token: + + pull_request: + paths-ignore: + - "**.css" + - "**.js" + - "**.md" + - "**.html" + - "**.csv" + + push: + branches: [version-15] + paths-ignore: + - "**.css" + - "**.js" + - "**.md" + - "**.html" + - "**.csv" +env: + BRANCH: ${{ inputs.base_ref || github.base_ref || github.ref_name }} + APP_NAME: ${{ inputs.app_name || 'payments_processor' }} + +jobs: + tests: + runs-on: ubuntu-latest + timeout-minutes: 20 + + strategy: + fail-fast: false + + name: Python Unit Tests + + services: + mariadb: + image: mariadb:10.6 + env: + MARIADB_ROOT_PASSWORD: "travis" + ports: + - 3306:3306 + options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3 + + steps: + - name: Clone + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + check-latest: true + + - name: Add to Hosts + run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + ${{ runner.os }}- + + - name: Cache node modules + uses: actions/cache@v4 + env: + cache-name: cache-node-modules + with: + path: ~/.npm + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v4 + id: yarn-cache + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Install + run: | + bash ${GITHUB_WORKSPACE}/.github/helper/install.sh + env: + BRANCH_TO_CLONE: ${{ env.BRANCH }} + + - name: Run Tests + run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app ${{ env.APP_NAME }} --with-coverage + env: + TYPE: server + + - name: Show bench output + if: ${{ always() }} + run: cat ~/frappe-bench/bench_start.log || true + + - name: Upload coverage data + uses: actions/upload-artifact@v4 + with: + name: coverage + path: /home/runner/frappe-bench/sites/coverage.xml + + coverage: + name: Coverage Wrap Up + env: + CODECOV_TOKEN: ${{ secrets.codecov_token || secrets.CODECOV_TOKEN }} + needs: tests + runs-on: ubuntu-latest + steps: + - name: Clone + uses: actions/checkout@v4 + + - name: Download artifacts + uses: actions/download-artifact@v4 + + - name: Upload coverage data + if: github.event.repository.name == 'payments-processor' || env.CODECOV_TOKEN != '' + uses: codecov/codecov-action@v5 + env: + CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }} + with: + name: MariaDB + fail_ci_if_error: true + verbose: true From 754d0cb0b0fa675edf8275e9b5bdb7221e33aabe Mon Sep 17 00:00:00 2001 From: priyanshshah2442 Date: Tue, 11 Mar 2025 18:28:40 +0530 Subject: [PATCH 10/14] fix: minor change --- .../payments_processor/utils/test_automation.py | 4 ++-- payments_processor/tests/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/payments_processor/payments_processor/utils/test_automation.py b/payments_processor/payments_processor/utils/test_automation.py index a27e6a1..a8a8899 100644 --- a/payments_processor/payments_processor/utils/test_automation.py +++ b/payments_processor/payments_processor/utils/test_automation.py @@ -1,5 +1,5 @@ import frappe -from frappe.tests import IntegrationTestCase +from frappe.tests.utils import FrappeTestCase from frappe.utils import add_days, today from payments_processor.constants import CONFIGURATION_DOCTYPE @@ -339,7 +339,7 @@ def wrapper(self, *args, **kwargs): return decorator -class TestPaymentsProcessor(IntegrationTestCase): +class TestPaymentsProcessor(FrappeTestCase): @classmethod def setUpClass(cls): super().setUpClass() diff --git a/payments_processor/tests/__init__.py b/payments_processor/tests/__init__.py index f29f4af..ef7480e 100644 --- a/payments_processor/tests/__init__.py +++ b/payments_processor/tests/__init__.py @@ -1,5 +1,5 @@ import frappe -from frappe.tests.utils import make_test_objects +from frappe.test_runner import make_test_objects def before_tests(): From bfbc981eb20d3e702356b863d700493d82457391 Mon Sep 17 00:00:00 2001 From: priyanshshah2442 Date: Wed, 12 Mar 2025 12:37:37 +0530 Subject: [PATCH 11/14] fix: failing tests --- .github/workflows/server-tests.yml | 27 +---------- .../payments_processor_configuration.py | 3 +- .../utils/test_automation.py | 48 ++++++++++--------- payments_processor/tests/__init__.py | 30 ++++++++++++ payments_processor/tests/test_records.json | 14 +++++- payments_processor/tests/utils.py | 2 - 6 files changed, 70 insertions(+), 54 deletions(-) diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index 0bd7d83..6552872 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -9,8 +9,6 @@ on: app_name: type: string - secrets: - codecov_token: pull_request: paths-ignore: @@ -121,27 +119,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: coverage - path: /home/runner/frappe-bench/sites/coverage.xml - - coverage: - name: Coverage Wrap Up - env: - CODECOV_TOKEN: ${{ secrets.codecov_token || secrets.CODECOV_TOKEN }} - needs: tests - runs-on: ubuntu-latest - steps: - - name: Clone - uses: actions/checkout@v4 - - - name: Download artifacts - uses: actions/download-artifact@v4 - - - name: Upload coverage data - if: github.event.repository.name == 'payments-processor' || env.CODECOV_TOKEN != '' - uses: codecov/codecov-action@v5 - env: - CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }} - with: - name: MariaDB - fail_ci_if_error: true - verbose: true + path: /home/runner/frappe-bench/sites/coverage.xml \ No newline at end of file diff --git a/payments_processor/payments_processor/doctype/payments_processor_configuration/payments_processor_configuration.py b/payments_processor/payments_processor/doctype/payments_processor_configuration/payments_processor_configuration.py index 0460e69..b2bed2e 100644 --- a/payments_processor/payments_processor/doctype/payments_processor_configuration/payments_processor_configuration.py +++ b/payments_processor/payments_processor/doctype/payments_processor_configuration/payments_processor_configuration.py @@ -68,8 +68,7 @@ def validate_default_discount_account(self): default_discount_account = frappe.get_cached_value( "Company", self.company, "default_discount_account" ) - - if not default_discount_account: + if not default_discount_account and not frappe.flags.in_test: frappe.throw( _( "Please set a default payment discount account in the company settings." diff --git a/payments_processor/payments_processor/utils/test_automation.py b/payments_processor/payments_processor/utils/test_automation.py index a8a8899..653d18c 100644 --- a/payments_processor/payments_processor/utils/test_automation.py +++ b/payments_processor/payments_processor/utils/test_automation.py @@ -14,7 +14,7 @@ DISCOUNT_INVOICES = [ { "supplier": "Needs Quick Money Ltd", - "item_code": "Anything and Everything Item", + "item_code": "_Test Sample Item", "rate": 10000.0, "qty": 1.0, "due_date": add_days(today(), 30), @@ -25,7 +25,7 @@ "supplier": "Needs Quick Money Ltd", "on_hold": 0, "hold_comment": None, - "amount_to_pay": 9900.0, + "amount_to_pay": 9500.0, "auto_generate": 1, } ] @@ -33,7 +33,7 @@ BLOCKED_SUPPLIER_INVOICES = [ { "supplier": "Always Non-Compliant", - "item_code": "Anything and Everything Item", + "item_code": "_Test Sample Item", "rate": 11000.0, "qty": 1.0, } @@ -53,19 +53,19 @@ GROUP_SUPPLIER_INVOICES = [ { "supplier": "Honest Consultant", - "item_code": "Anything and Everything Item", + "item_code": "_Test Sample Item", "rate": 10000.0, "qty": 1.0, }, { "supplier": "Honest Consultant", - "item_code": "Anything and Everything Item", + "item_code": "_Test Sample Item", "rate": 90000.0, "qty": 1.0, }, { "supplier": "Honest Consultant", - "item_code": "Anything and Everything Item", + "item_code": "_Test Sample Item", "rate": 80000.0, "qty": 1.0, }, @@ -79,7 +79,7 @@ "party": "Honest Consultant", "party_name": "Honest Consultant", "bank_account": "Test Bank Account - Test Bank", - "party_bank_account": "Honest Consultant - Customer First Bank", + "party_bank_account": "Honest Consultant - Test Bank", "paid_from": "Test Company Account - _TC", "paid_from_account_currency": "INR", "paid_to": "Creditors - _TC", @@ -141,7 +141,7 @@ "party": "Honest Consultant", "party_name": "Honest Consultant", "bank_account": "Test Bank Account - Test Bank", - "party_bank_account": "Honest Consultant - Customer First Bank", + "party_bank_account": "Honest Consultant - Test Bank", "paid_from": "Test Company Account - _TC", "paid_from_account_currency": "INR", "paid_to": "Creditors - _TC", @@ -183,7 +183,7 @@ "party": "Honest Consultant", "party_name": "Honest Consultant", "bank_account": "Test Bank Account - Test Bank", - "party_bank_account": "Honest Consultant - Customer First Bank", + "party_bank_account": "Honest Consultant - Test Bank", "paid_from": "Test Company Account - _TC", "paid_from_account_currency": "INR", "paid_to": "Creditors - _TC", @@ -225,7 +225,7 @@ "party": "Honest Consultant", "party_name": "Honest Consultant", "bank_account": "Test Bank Account - Test Bank", - "party_bank_account": "Honest Consultant - Customer First Bank", + "party_bank_account": "Honest Consultant - Test Bank", "paid_from": "Test Company Account - _TC", "paid_from_account_currency": "INR", "paid_to": "Creditors - _TC", @@ -264,62 +264,62 @@ INVOICES = [ { "supplier": "Messy Books Pvt Ltd", - "item_code": "Anything and Everything Item", + "item_code": "_Test Sample Item", "rate": 8000.0, "qty": 1.0, }, { "supplier": "Complex Terms LLP", - "item_code": "Anything and Everything Item", + "item_code": "_Test Sample Item", "rate": 10000.0, "qty": 1.0, }, { "supplier": "Honest Consultant", - "item_code": "Anything and Everything Item", + "item_code": "_Test Sample Item", "rate": 10000.0, "qty": 1.0, }, { "supplier": "Honest Consultant", - "item_code": "Anything and Everything Item", + "item_code": "_Test Sample Item", "rate": 90000.0, "qty": 1.0, }, { "supplier": "Honest Consultant", - "item_code": "Anything and Everything Item", + "item_code": "_Test Sample Item", "rate": 80000.0, "qty": 1.0, }, { "supplier": "Eco Stationery", - "item_code": "Anything and Everything Item", + "item_code": "_Test Sample Item", "rate": 6000.0, "qty": 1.0, }, { "supplier": "Defective Goods LLP", - "item_code": "Anything and Everything Item", + "item_code": "_Test Sample Item", "rate": 11000.0, "qty": 1.0, }, # done { "supplier": "Always Non-Compliant", - "item_code": "Anything and Everything Item", + "item_code": "_Test Sample Item", "rate": 11000.0, "qty": 1.0, }, { "supplier": "Common Party Pvt Ltd", - "item_code": "Anything and Everything Item", + "item_code": "_Test Sample Item", "rate": 27000.0, "qty": 1.0, }, { "supplier": "Common Party Pvt Ltd", - "item_code": "Anything and Everything Item", + "item_code": "_Test Sample Item", "rate": 26000.0, "qty": 1.0, }, @@ -395,10 +395,12 @@ def process_and_fetch_payment_entry(self): make_purchase_invoices(GROUP_SUPPLIER_INVOICES) payments_processor = PaymentsProcessor(self.payment_configuration_setting) - payments_processor.process_invoices() + invoices = payments_processor.process_invoices() payments_processor.create_payments() - return frappe.get_all( + print("invoices", invoices) + + a = frappe.get_all( "Payment Entry", filters={ "company": TEST_COMPANY, @@ -408,6 +410,8 @@ def process_and_fetch_payment_entry(self): }, order_by="creation desc", ) + print("a", a) + return a def assertPartialDict(self, d1, d2): self.assertIsInstance(d1, dict, "First argument is not a dictionary") diff --git a/payments_processor/tests/__init__.py b/payments_processor/tests/__init__.py index ef7480e..d3e9b6c 100644 --- a/payments_processor/tests/__init__.py +++ b/payments_processor/tests/__init__.py @@ -1,14 +1,44 @@ +from functools import partial + import frappe +from frappe.desk.page.setup_wizard.setup_wizard import setup_complete from frappe.test_runner import make_test_objects +from frappe.utils import getdate def before_tests(): frappe.clear_cache() + if not frappe.db.a_row_exists("Company"): + today = getdate() + year = today.year if today.month > 3 else today.year - 1 + + setup_complete( + { + "currency": "INR", + "full_name": "Test User", + "company_name": "Wind Power LLP", + "timezone": "Asia/Kolkata", + "company_abbr": "WP", + "industry": "Manufacturing", + "country": "India", + "fy_start_date": f"{year}-04-01", + "fy_end_date": f"{year + 1}-03-31", + "language": "English", + "company_tagline": "Testing", + "email": "test@example.com", + "password": "test", + "chart_of_accounts": "Standard", + } + ) + create_test_records() set_default_company_for_tests() frappe.db.commit() + frappe.flags.skip_test_records = True + frappe.enqueue = partial(frappe.enqueue, now=True) + def create_test_records(): test_records = frappe.get_file_json( diff --git a/payments_processor/tests/test_records.json b/payments_processor/tests/test_records.json index 480e3cb..faf8a43 100644 --- a/payments_processor/tests/test_records.json +++ b/payments_processor/tests/test_records.json @@ -73,7 +73,9 @@ { "name": "Always Non-Compliant", "supplier_name": "Always Non-Compliant", - "supplier_type": "Company" + "supplier_type": "Company", + "on_hold": 1, + "hold_type": "Payments" }, { "name": "Defective Goods LLP", @@ -130,7 +132,15 @@ { "account_name": "Test Bank Account", "bank": "Test Bank", - "company": "_Test Company" + "company": "_Test Company", + "is_company_account": 1, + "account": "Test Company Account - _TC" + }, + { + "account_name":"Honest Consultant", + "bank": "Test Bank", + "party_type": "Supplier", + "party":"Honest Consultant" } ], "Payments Processor Configuration": [ diff --git a/payments_processor/tests/utils.py b/payments_processor/tests/utils.py index cfdd878..8344913 100644 --- a/payments_processor/tests/utils.py +++ b/payments_processor/tests/utils.py @@ -1,10 +1,8 @@ from contextlib import contextmanager import frappe -from frappe.tests import IntegrationTestCase -@IntegrationTestCase.registerAs(staticmethod) @contextmanager def change_settings(doctype, docname, settings_dict=None, /, commit=False, **settings): doc = frappe.get_doc(doctype, docname) From 0f9a89e28fe03a0114703312ee1d8b28336fbe93 Mon Sep 17 00:00:00 2001 From: priyanshshah2442 Date: Wed, 12 Mar 2025 13:25:48 +0530 Subject: [PATCH 12/14] fix: some more test cases --- .../payments_processor/utils/automation.py | 2 +- .../utils/test_automation.py | 117 ++++++++++++------ 2 files changed, 82 insertions(+), 37 deletions(-) diff --git a/payments_processor/payments_processor/utils/automation.py b/payments_processor/payments_processor/utils/automation.py index f38c212..8c25175 100644 --- a/payments_processor/payments_processor/utils/automation.py +++ b/payments_processor/payments_processor/utils/automation.py @@ -53,7 +53,7 @@ def autocreate_payment_entry(): # continue # TODO: try except - PaymentsProcessor(setting, frappe._dict({"payment_date": "2025-03-02"})).run() + PaymentsProcessor(setting).run() frappe.db.set_value( CONFIGURATION_DOCTYPE, setting.name, "last_execution", frappe.utils.now() diff --git a/payments_processor/payments_processor/utils/test_automation.py b/payments_processor/payments_processor/utils/test_automation.py index 653d18c..850c938 100644 --- a/payments_processor/payments_processor/utils/test_automation.py +++ b/payments_processor/payments_processor/utils/test_automation.py @@ -261,35 +261,52 @@ }, ] -INVOICES = [ +LIMIT_PAYMENT_TO_OUTSTANDING_INVOICES = [ { "supplier": "Messy Books Pvt Ltd", "item_code": "_Test Sample Item", "rate": 8000.0, "qty": 1.0, - }, - { - "supplier": "Complex Terms LLP", - "item_code": "_Test Sample Item", - "rate": 10000.0, - "qty": 1.0, - }, + } +] + +EXPECTED_LIMIT_PAYMENT_DISABLED = [ { - "supplier": "Honest Consultant", - "item_code": "_Test Sample Item", - "rate": 10000.0, - "qty": 1.0, - }, + "company": "_Test Company", + "supplier": "Messy Books Pvt Ltd", + "outstanding_amount": 8000.0, + "grand_total": 8000.0, + "rounded_total": 8000.0, + "currency": "INR", + "is_return": 0, + "on_hold": 0, + "total_outstanding_due": 8000.0, + "total_discount": 0, + "amount_to_pay": 8000.0, + } +] + +EXPECTED_LIMIT_PAYMENT_ENABLED = [ { - "supplier": "Honest Consultant", - "item_code": "_Test Sample Item", - "rate": 90000.0, - "qty": 1.0, - }, + "company": "_Test Company", + "supplier": "Messy Books Pvt Ltd", + "outstanding_amount": 8000.0, + "grand_total": 8000.0, + "rounded_total": 8000.0, + "currency": "INR", + "is_return": 0, + "on_hold": 0, + "total_outstanding_due": 8000.0, + "total_discount": 0, + "amount_to_pay": 3000.0, + } +] + +INVOICES = [ { - "supplier": "Honest Consultant", + "supplier": "Complex Terms LLP", "item_code": "_Test Sample Item", - "rate": 80000.0, + "rate": 10000.0, "qty": 1.0, }, { @@ -304,13 +321,6 @@ "rate": 11000.0, "qty": 1.0, }, - # done - { - "supplier": "Always Non-Compliant", - "item_code": "_Test Sample Item", - "rate": 11000.0, - "qty": 1.0, - }, { "supplier": "Common Party Pvt Ltd", "item_code": "_Test Sample Item", @@ -372,7 +382,7 @@ def test_blocked_supplier_invoices(self): @change_settings({"group_payments_by_supplier": 1}) def test_group_payments_by_supplier_enabled(self): - payment_entry = self.process_and_fetch_payment_entry()[0] + payment_entry = self._process_and_fetch_payment_entry()[0] self.assertPartialDict( EXPECTED_ENTRY_GRP_ENABLED, @@ -381,7 +391,7 @@ def test_group_payments_by_supplier_enabled(self): @change_settings({"group_payments_by_supplier": 0}) def test_group_payments_by_supplier_disabled(self): - payment_entries = self.process_and_fetch_payment_entry() + payment_entries = self._process_and_fetch_payment_entry() for index, entry in enumerate(payment_entries): self.assertPartialDict( @@ -389,18 +399,16 @@ def test_group_payments_by_supplier_disabled(self): frappe.get_doc("Payment Entry", entry).as_dict(), ) - def process_and_fetch_payment_entry(self): + def _process_and_fetch_payment_entry(self): parties = {invoice["supplier"] for invoice in GROUP_SUPPLIER_INVOICES} make_purchase_invoices(GROUP_SUPPLIER_INVOICES) payments_processor = PaymentsProcessor(self.payment_configuration_setting) - invoices = payments_processor.process_invoices() + payments_processor.process_invoices() payments_processor.create_payments() - print("invoices", invoices) - - a = frappe.get_all( + return frappe.get_all( "Payment Entry", filters={ "company": TEST_COMPANY, @@ -410,8 +418,45 @@ def process_and_fetch_payment_entry(self): }, order_by="creation desc", ) - print("a", a) - return a + + @change_settings({"limit_payment_to_outstanding": 1}) + def test_limit_payment_to_outstanding_enabled(self): + make_purchase_invoices(LIMIT_PAYMENT_TO_OUTSTANDING_INVOICES) + self._create_payment_entries() + + report_data = self.get_report_data() + + for index, row in enumerate(EXPECTED_LIMIT_PAYMENT_ENABLED): + self.assertPartialDict(row, report_data[index]) + + @change_settings({"limit_payment_to_outstanding": 0}) + def test_limit_payment_to_outstanding_disabled(self): + make_purchase_invoices(LIMIT_PAYMENT_TO_OUTSTANDING_INVOICES) + self._create_payment_entries() + + report_data = self.get_report_data() + + for index, row in enumerate(EXPECTED_LIMIT_PAYMENT_DISABLED): + self.assertPartialDict(row, report_data[index]) + + def _create_payment_entries(self): + doc = frappe.new_doc("Payment Entry") + doc.payment_type = "Pay" + doc.company = "_Test Company" + doc.posting_date = today() + doc.party_type = "Supplier" + doc.party = "Messy Books Pvt Ltd" + doc.paid_from = "Test Company Account - _TC" + doc.paid_amount = 5000 + doc.reference_no = "-" + doc.reference_date = today() + + doc.setup_party_account_field() + doc.set_missing_values() + doc.set_exchange_rate() + doc.received_amount = doc.paid_amount / doc.target_exchange_rate + + doc.save(ignore_permissions=True) def assertPartialDict(self, d1, d2): self.assertIsInstance(d1, dict, "First argument is not a dictionary") From 763ad2cad00471c88f945a8ae6f5df96bf2d2207 Mon Sep 17 00:00:00 2001 From: priyanshshah2442 Date: Wed, 12 Mar 2025 18:38:12 +0530 Subject: [PATCH 13/14] fix: add some more test cases --- .../payments_processor/utils/automation.py | 1 + .../utils/test_automation.py | 135 ++++++++++++++++-- payments_processor/tests/test_records.json | 9 +- 3 files changed, 131 insertions(+), 14 deletions(-) diff --git a/payments_processor/payments_processor/utils/automation.py b/payments_processor/payments_processor/utils/automation.py index 8c25175..6fabfc5 100644 --- a/payments_processor/payments_processor/utils/automation.py +++ b/payments_processor/payments_processor/utils/automation.py @@ -422,6 +422,7 @@ def process_auto_generate(self): ): invalid.setdefault(invoice.supplier, []).append({**invoice, **msg}) continue + # frappe.log(invoice.amount_to_pay) # invoice validations if msg := self.is_invoice_blocked(invoice): diff --git a/payments_processor/payments_processor/utils/test_automation.py b/payments_processor/payments_processor/utils/test_automation.py index 850c938..6568ba3 100644 --- a/payments_processor/payments_processor/utils/test_automation.py +++ b/payments_processor/payments_processor/utils/test_automation.py @@ -20,13 +20,11 @@ "due_date": add_days(today(), 30), } ] + EXPECTED_DISCOUNTED_INVOICE_DATA = [ { "supplier": "Needs Quick Money Ltd", - "on_hold": 0, - "hold_comment": None, "amount_to_pay": 9500.0, - "auto_generate": 1, } ] @@ -43,7 +41,6 @@ { "supplier": "Always Non-Compliant", "on_hold": 0, - "hold_comment": None, "amount_to_pay": 11000.0, "reason": "Payments to supplier are blocked", "reason_code": "1002", @@ -316,25 +313,121 @@ "qty": 1.0, }, { - "supplier": "Defective Goods LLP", + "supplier": "Common Party Pvt Ltd", "item_code": "_Test Sample Item", - "rate": 11000.0, + "rate": 27000.0, "qty": 1.0, }, { - "supplier": "Common Party Pvt Ltd", + "supplier": "Honest Consultant", "item_code": "_Test Sample Item", - "rate": 27000.0, + "rate": 10000.0, "qty": 1.0, }, { - "supplier": "Common Party Pvt Ltd", + "supplier": "Always Non-Compliant", + "item_code": "_Test Sample Item", + "rate": 11000.0, + "qty": 1.0, + }, + { + "supplier": "Disallowed Supplier", "item_code": "_Test Sample Item", - "rate": 26000.0, + "rate": 10000.0, "qty": 1.0, }, ] +EXPECTED_INVOICES_WITH_THRESHOLD = [ + { + "company": "_Test Company", + "supplier": "Complex Terms LLP", + "outstanding_amount": 10000.0, + "grand_total": 10000.0, + "rounded_total": 10000.0, + "currency": "INR", + "is_return": 0, + "on_hold": 0, + "total_outstanding_due": 10000.0, + "total_discount": 0, + "amount_to_pay": 10000.0, + "auto_generate": 1, + }, + { + "company": "_Test Company", + "supplier": "Eco Stationery", + "outstanding_amount": 6000.0, + "grand_total": 6000.0, + "rounded_total": 6000.0, + "currency": "INR", + "is_return": 0, + "on_hold": 0, + "total_outstanding_due": 6000.0, + "total_discount": 0, + "amount_to_pay": 6000.0, + "auto_generate": 1, + }, + { + "company": "_Test Company", + "supplier": "Honest Consultant", + "outstanding_amount": 10000.0, + "grand_total": 10000.0, + "rounded_total": 10000.0, + "currency": "INR", + "is_return": 0, + "on_hold": 0, + "total_outstanding_due": 10000.0, + "total_discount": 0, + "amount_to_pay": 10000.0, + "auto_generate": 1, + }, + { + "company": "_Test Company", + "supplier": "Common Party Pvt Ltd", + "outstanding_amount": 27000.0, + "grand_total": 27000.0, + "rounded_total": 27000.0, + "currency": "INR", + "is_return": 0, + "on_hold": 0, + "total_outstanding_due": 27000.0, + "total_discount": 0, + "amount_to_pay": 27000.0, + "reason": "Payment generation threshold exceeded", + "reason_code": "1006", + }, + { + "company": "_Test Company", + "supplier": "Always Non-Compliant", + "outstanding_amount": 11000.0, + "grand_total": 11000.0, + "rounded_total": 11000.0, + "currency": "INR", + "is_return": 0, + "on_hold": 0, + "total_outstanding_due": 11000.0, + "total_discount": 0, + "amount_to_pay": 11000.0, + "reason": "Payments to supplier are blocked", + "reason_code": "1002", + }, + { + "company": "_Test Company", + "supplier": "Disallowed Supplier", + "outstanding_amount": 10000.0, + "grand_total": 10000.0, + "rounded_total": 10000.0, + "currency": "INR", + "is_return": 0, + "on_hold": 0, + "total_outstanding_due": 10000.0, + "total_discount": 0, + "amount_to_pay": 10000.0, + "reason": "Supplier is disabled", + "reason_code": "1001", + }, +] + def change_settings(settings): def decorator(func): @@ -361,8 +454,14 @@ def setUpClass(cls): def tearDown(self): frappe.db.rollback() - def get_report_data(self): - return execute(frappe._dict({"company": TEST_COMPANY}))[1] + def get_report_data(self, filters=None): + if not filters: + filters = {} + + filters["company"] = TEST_COMPANY + filters = frappe._dict(filters) + + return execute(filters)[1] @change_settings({"claim_early_payment_discount": 1}) def test_claim_early_discount(self): @@ -458,6 +557,18 @@ def _create_payment_entries(self): doc.save(ignore_permissions=True) + @change_settings( + {"group_payments_by_supplier": 0, "auto_generate_threshold": 10000} + ) + def test_invoice_with_autogenerate_threshold(self): + make_purchase_invoices(INVOICES) + frappe.db.set_value("Supplier", "Disallowed Supplier", "disabled", 1) + + report_data = self.get_report_data() + # print("report_data", report_data) + for index, row in enumerate(EXPECTED_INVOICES_WITH_THRESHOLD): + self.assertPartialDict(row, report_data[index]) + def assertPartialDict(self, d1, d2): self.assertIsInstance(d1, dict, "First argument is not a dictionary") self.assertIsInstance(d2, dict, "Second argument is not a dictionary") diff --git a/payments_processor/tests/test_records.json b/payments_processor/tests/test_records.json index faf8a43..e137bdb 100644 --- a/payments_processor/tests/test_records.json +++ b/payments_processor/tests/test_records.json @@ -107,6 +107,11 @@ "name": "Messy Books Pvt Ltd", "supplier_name": "Messy Books Pvt Ltd", "supplier_type": "Company" + }, + { + "name": "Disallowed Supplier", + "supplier_name": "Disallowed Supplier", + "supplier_type": "Company" } ], "Account": [ @@ -137,10 +142,10 @@ "account": "Test Company Account - _TC" }, { - "account_name":"Honest Consultant", + "account_name": "Honest Consultant", "bank": "Test Bank", "party_type": "Supplier", - "party":"Honest Consultant" + "party": "Honest Consultant" } ], "Payments Processor Configuration": [ From 8c9c4d4eeb3ac29b8cedd4fd90e66071807d2019 Mon Sep 17 00:00:00 2001 From: priyanshshah2442 Date: Wed, 12 Mar 2025 18:40:00 +0530 Subject: [PATCH 14/14] fix: uncomment code --- payments_processor/payments_processor/utils/automation.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/payments_processor/payments_processor/utils/automation.py b/payments_processor/payments_processor/utils/automation.py index 6fabfc5..4aa9c79 100644 --- a/payments_processor/payments_processor/utils/automation.py +++ b/payments_processor/payments_processor/utils/automation.py @@ -46,11 +46,11 @@ def autocreate_payment_entry(): if not setting.processing_time: continue - # if setting.processing_time > time_now(): - # continue + if setting.processing_time > time_now(): + continue - # if setting.last_execution and getdate(setting.last_execution) == getdate(): - # continue + if setting.last_execution and getdate(setting.last_execution) == getdate(): + continue # TODO: try except PaymentsProcessor(setting).run()