", "exec"))
+ else:
+ items[line] = outcome(lambda l=line: importlib.import_module(l.split()[0]).execute)
+ self.assert_all_pass("patches", items)
+
+ def test_custom_fields_installed(self):
+ items = {}
+ folder = os.path.join(APP_PATH, "patches", "custom_fields", "custom_fields_json")
+ for file in sorted(os.listdir(folder)):
+ for field in json.load(open(os.path.join(folder, file))):
+ if not frappe.db.exists("DocType", field["dt"]):
+ items[f"{file}: {field['dt']}.{field['fieldname']}"] = {
+ "status": "validation",
+ "error": "DocType not installed",
+ }
+ continue
+ exists = frappe.db.exists(
+ "Custom Field", {"dt": field["dt"], "fieldname": field["fieldname"]}
+ )
+ items[f"{file}: {field['dt']}.{field['fieldname']}"] = {
+ "status": "pass" if exists else "fail",
+ "error": "" if exists else "missing",
+ }
+ self.assert_all_pass("custom_fields", items)
+
+ def test_property_setters_installed(self):
+ items = {}
+ folder = os.path.join(APP_PATH, "patches", "property_setter", "property_setter_json")
+ for file in sorted(os.listdir(folder)):
+ for setter in json.load(open(os.path.join(folder, file))):
+ key = f"{file}: {setter['doc_type']}.{setter.get('field_name')}.{setter['property']}"
+ if not frappe.db.exists("DocType", setter["doc_type"]):
+ items[key] = {"status": "validation", "error": "DocType not installed"}
+ continue
+ filters = {"doc_type": setter["doc_type"], "property": setter["property"]}
+ if setter.get("field_name"):
+ filters["field_name"] = setter["field_name"]
+ exists = frappe.db.exists("Property Setter", filters)
+ items[key] = {"status": "pass" if exists else "fail", "error": "" if exists else "missing"}
+ self.assert_all_pass("property_setters", items)
+
+ def test_override_doctype_classes_are_active(self):
+ for doctype, path in hooks.override_doctype_class.items():
+ cls = frappe.get_attr(path)
+ self.assertIsInstance(frappe.new_doc(doctype), cls, f"{doctype} does not use {path}")
+
+ def test_monkey_patch_is_loaded(self):
+ from frappe.database.database import Database
+
+ csf_tz.load_monkey_patches()
+ self.assertEqual(
+ Database.check_transaction_status.__module__, "csf_tz.monkey_patches.db_transaction_writes"
+ )
+
+ def test_whitelisted_methods_are_registered(self):
+ items = {}
+ inventory = os.path.join(APP_PATH, "tests", "whitelisted_methods.json")
+ if not os.path.exists(inventory):
+ self.skipTest("no whitelisted_methods.json inventory")
+ for entry in json.load(open(inventory)):
+ if entry.get("in_class"):
+ continue
+
+ def check(path=entry["path"]):
+ fn = frappe.get_attr(path)
+ frappe.is_whitelisted(fn)
+
+ items[entry["path"]] = outcome(check)
+ self.assert_all_pass("whitelisted", items)
+
+ def test_jinja_qrcode_method(self):
+ from csf_tz.custom_api import generate_qrcode
+
+ self.assertTrue(
+ generate_qrcode("hello").startswith("data:image/png;base64,")
+ or len(generate_qrcode("hello")) > 20
+ )
+ self.assertIn("csf_tz.custom_api.generate_qrcode", frappe.get_hooks("jinja")["methods"])
+
+ def test_config_modules(self):
+ items = {}
+ for name in [
+ "desktop",
+ "csf_tz",
+ "accounts",
+ "purchase_and_stock_management",
+ "stock",
+ "sales_and_marketing",
+ ]:
+ items[name] = outcome(lambda n=name: importlib.import_module(f"csf_tz.config.{n}").get_data())
+ self.assert_all_pass("config", items)
+
+ def test_setup_data_files(self):
+ from csf_tz.utils.setup import SETUP_SPECS, load_records
+
+ items = {}
+ for spec in SETUP_SPECS:
+ items[spec["file"]] = outcome(
+ lambda s=spec: (load_records(s["file"]), frappe.get_meta(s["doctype"]))
+ )
+ self.assert_all_pass("setup_data", items)
+
+ def test_dashboard_chart_source(self):
+ from csf_tz.csf_tz.dashboard_chart_source.multi_account_balance_timeline import (
+ multi_account_balance_timeline as src,
+ )
+
+ chart = frappe.db.exists("Dashboard Chart", "Multi Bank Balance")
+ self.assertTrue(chart, "Dashboard Chart 'Multi Bank Balance' not installed")
+ result = src.get(chart_name="Multi Bank Balance", filters=json.dumps({"company": self.company}))
+ self.assertIn("datasets", result)
diff --git a/csf_tz/tests/test_vehicle_authority.py b/csf_tz/tests/test_vehicle_authority.py
new file mode 100644
index 00000000..3795f56a
--- /dev/null
+++ b/csf_tz/tests/test_vehicle_authority.py
@@ -0,0 +1,175 @@
+from unittest.mock import patch
+
+import frappe
+from frappe.tests import IntegrationTestCase
+
+from csf_tz import vehicle_authority
+from csf_tz.vehicle_authority import (
+ get_authority_notification_recipients,
+ get_authority_notification_roles,
+ get_unique_vehicle_plates,
+ get_vehicle_docname_by_plate,
+ get_vehicle_like_doctypes,
+ get_vehicle_like_records,
+ get_vehicle_plate,
+ has_vehicle_plate_field,
+ is_authority_notification_enabled,
+ is_authority_notification_event_enabled,
+ run_daily_authority_notifications,
+ send_authority_notification,
+)
+
+AUTHORITY_ROLE = "_Test Authority Notification Role"
+AUTHORITY_USER = "test@example.com"
+
+
+def make_vehicle(plate):
+ if frappe.db.exists("Vehicle", plate):
+ return frappe.get_doc("Vehicle", plate)
+ return frappe.get_doc(
+ {
+ "doctype": "Vehicle",
+ "license_plate": plate,
+ "make": "Toyota",
+ "model": "Hilux",
+ "last_odometer": 100,
+ "fuel_type": "Diesel",
+ "uom": "Nos",
+ }
+ ).insert()
+
+
+def make_authority_role():
+ if not frappe.db.exists("Role", AUTHORITY_ROLE):
+ frappe.get_doc({"doctype": "Role", "role_name": AUTHORITY_ROLE}).insert()
+ frappe.get_doc("User", AUTHORITY_USER).add_roles(AUTHORITY_ROLE)
+
+
+def configure_authority_notifications(reference_types=(), **flags):
+ """Enable notifications for the given reference types and route them to AUTHORITY_ROLE."""
+ make_authority_role()
+ settings = frappe.get_single("CSF TZ Settings")
+ settings.authority_notification_roles = []
+ for reference_type in reference_types:
+ settings.append(
+ "authority_notification_roles", {"reference_type": reference_type, "role": AUTHORITY_ROLE}
+ )
+ for fieldname in vehicle_authority.AUTHORITY_ENABLE_FIELD_MAP.values():
+ settings.set(fieldname, 0)
+ for fieldname in vehicle_authority.AUTHORITY_EVENT_ENABLE_FIELD_MAP.values():
+ settings.set(fieldname, 0)
+ for reference_type in reference_types:
+ settings.set(vehicle_authority.AUTHORITY_ENABLE_FIELD_MAP[reference_type], 1)
+ for fieldname, value in flags.items():
+ settings.set(fieldname, value)
+ settings.save(ignore_permissions=True)
+ return settings
+
+
+class TestVehicleAuthority(IntegrationTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.vehicle = make_vehicle("T111AAA")
+
+ def test_get_vehicle_plate_from_document_and_values(self):
+ self.assertEqual(get_vehicle_plate(self.vehicle), "T111AAA")
+ meta = frappe.get_meta("Vehicle")
+ self.assertEqual(get_vehicle_plate(frappe._dict(license_plate=" T222BBB "), meta=meta), "T222BBB")
+ self.assertIsNone(get_vehicle_plate(frappe._dict(license_plate=""), meta=meta))
+
+ def test_has_vehicle_plate_field(self):
+ self.assertTrue(has_vehicle_plate_field(frappe.get_meta("Vehicle")))
+ self.assertFalse(has_vehicle_plate_field(frappe.get_meta("Item")))
+
+ def test_vehicle_like_doctypes_and_records(self):
+ doctypes = [doctype for doctype, _meta in get_vehicle_like_doctypes()]
+ self.assertIn("Vehicle", doctypes)
+
+ records = [record for record in get_vehicle_like_records() if record.doctype == "Vehicle"]
+ self.assertIn(
+ ("Vehicle", "T111AAA", "T111AAA"), [(r.doctype, r.name, r.plate_number) for r in records]
+ )
+
+ def test_get_vehicle_docname_by_plate(self):
+ self.assertEqual(get_vehicle_docname_by_plate("T111AAA"), self.vehicle.name)
+ self.assertIsNone(get_vehicle_docname_by_plate("T999ZZZ"))
+ self.assertIsNone(get_vehicle_docname_by_plate(None))
+
+ def test_get_unique_vehicle_plates(self):
+ make_vehicle("t 333 ccc")
+ plates = get_unique_vehicle_plates(
+ normalize_number_plate=lambda plate: plate.replace(" ", "").upper(),
+ is_valid_number_plate=lambda plate: plate != "T111AAA",
+ )
+ self.assertIn("T333CCC", plates)
+ self.assertNotIn("T111AAA", plates)
+ self.assertIn("T111AAA", get_unique_vehicle_plates())
+
+ def test_notification_roles_and_recipients(self):
+ configure_authority_notifications(["Vehicle Fine"])
+ self.assertEqual(get_authority_notification_roles("Vehicle Fine"), [AUTHORITY_ROLE])
+ self.assertEqual(get_authority_notification_roles("LATRA License"), [])
+ self.assertEqual(get_authority_notification_roles("Unknown"), [])
+ self.assertEqual(get_authority_notification_recipients("Vehicle Fine"), [AUTHORITY_USER])
+ self.assertEqual(get_authority_notification_recipients("TIRA"), [])
+
+ def test_disabled_users_are_not_recipients(self):
+ configure_authority_notifications(["TIRA"])
+ frappe.db.set_value("User", AUTHORITY_USER, "enabled", 0)
+ try:
+ self.assertEqual(get_authority_notification_recipients("TIRA"), [])
+ finally:
+ frappe.db.set_value("User", AUTHORITY_USER, "enabled", 1)
+
+ def test_enabled_flags(self):
+ configure_authority_notifications(["LATRA Offence"], latra_offence_notify_on_new=1)
+ self.assertTrue(is_authority_notification_enabled("LATRA Offence"))
+ self.assertFalse(is_authority_notification_enabled("TIRA"))
+ self.assertFalse(is_authority_notification_enabled("Unknown"))
+ self.assertTrue(is_authority_notification_event_enabled("LATRA Offence", "new"))
+ self.assertFalse(is_authority_notification_event_enabled("LATRA Offence", "status_change"))
+ self.assertTrue(is_authority_notification_event_enabled("LATRA Offence", "unknown_event"))
+
+ def test_send_authority_notification(self):
+ configure_authority_notifications([])
+ self.assertEqual(send_authority_notification("TIRA", "s", "m"), {"sent": False, "reason": "disabled"})
+
+ settings = configure_authority_notifications(["TIRA"])
+ settings.authority_notification_roles = []
+ settings.save(ignore_permissions=True)
+ self.assertEqual(
+ send_authority_notification("TIRA", "s", "m"), {"sent": False, "reason": "no_recipients"}
+ )
+
+ configure_authority_notifications(["TIRA"])
+ with patch("frappe.sendmail") as sendmail:
+ result = send_authority_notification("TIRA", "Subject", "Message")
+ self.assertTrue(result["sent"])
+ self.assertEqual(result["recipients"], [AUTHORITY_USER])
+ sendmail.assert_called_once_with(
+ recipients=[AUTHORITY_USER], subject="Subject", message="Message", now=True
+ )
+
+ def test_run_daily_authority_notifications_calls_every_notifier(self):
+ targets = [
+ "csf_tz.csf_tz.doctype.latra_licenses.latra_licenses.notify_latra_license_expiry",
+ "csf_tz.csf_tz.doctype.latra_licenses.latra_licenses.send_pending_latra_offence_notifications",
+ "csf_tz.csf_tz.doctype.tz_insurance_cover_note.tz_insurance_cover_note.notify_tira_covernote_expiry",
+ "csf_tz.csf_tz.doctype.vehicle_fine_record.vehicle_fine_record.send_pending_vehicle_fine_notifications",
+ ]
+ patchers = [patch(target) for target in targets]
+ mocks = [patcher.start() for patcher in patchers]
+ try:
+ run_daily_authority_notifications()
+ finally:
+ for patcher in patchers:
+ patcher.stop()
+ for mock in mocks:
+ mock.assert_called_once_with()
+
+ def test_run_daily_authority_notifications_end_to_end_with_nothing_enabled(self):
+ configure_authority_notifications([])
+ with patch.object(frappe.db, "commit"), patch("frappe.sendmail") as sendmail:
+ run_daily_authority_notifications()
+ sendmail.assert_not_called()
diff --git a/csf_tz/tests/test_vehicle_fine_record.py b/csf_tz/tests/test_vehicle_fine_record.py
new file mode 100644
index 00000000..6beb8552
--- /dev/null
+++ b/csf_tz/tests/test_vehicle_fine_record.py
@@ -0,0 +1,212 @@
+import base64
+import hashlib
+import json
+from unittest.mock import MagicMock, patch
+
+import frappe
+import requests
+from cryptography.hazmat.primitives import padding
+from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+from frappe.tests import IntegrationTestCase
+
+from csf_tz.csf_tz.doctype.vehicle_fine_record import vehicle_fine_record as module
+from csf_tz.csf_tz.doctype.vehicle_fine_record.vehicle_fine_record import (
+ TPF_SECRET,
+ check_fine_all_vehicles,
+ decode_tpf_response,
+ get_fine,
+ is_valid_number_plate,
+ normalize_number_plate,
+ send_pending_vehicle_fine_notifications,
+ sync_vehicle_fines,
+)
+from csf_tz.tests.test_vehicle_authority import (
+ AUTHORITY_USER,
+ configure_authority_notifications,
+ make_vehicle,
+)
+
+PLATE = "T444DDD"
+
+
+def encrypt_tpf_payload(data):
+ key = TPF_SECRET[:32].ljust(32, "\0").encode()
+ iv = hashlib.sha256(TPF_SECRET.encode()).hexdigest()[:16].encode()
+ padder = padding.PKCS7(128).padder()
+ padded = padder.update(json.dumps(data).encode()) + padder.finalize()
+ encryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor()
+ return base64.b64encode(encryptor.update(padded) + encryptor.finalize()).decode()
+
+
+def tpf_response(pending_transactions, status_code=200):
+ response = MagicMock(status_code=status_code, text="ok")
+ response.json.return_value = {"pending_transactions": pending_transactions}
+ return response
+
+
+def fine(reference, status="PENDING", charge=30000, penalty=0):
+ return {
+ "reference": reference,
+ "status": status,
+ "charge": charge,
+ "penalty": penalty,
+ "offence": "Speeding",
+ "issued_date": "2026-01-15",
+ "licence": "LIC-1",
+ "location": "Dar es Salaam",
+ "officer": "Officer One",
+ }
+
+
+class TestVehicleFinePlates(IntegrationTestCase):
+ def test_normalize_number_plate(self):
+ self.assertEqual(normalize_number_plate(" t-123 abc "), "T123ABC")
+ self.assertEqual(normalize_number_plate("T123ABCXYZ"), "T123ABC")
+ self.assertIsNone(normalize_number_plate(""))
+ self.assertIsNone(normalize_number_plate("---"))
+
+ def test_is_valid_number_plate(self):
+ self.assertTrue(is_valid_number_plate("T123ABC"))
+ self.assertTrue(is_valid_number_plate("TZ999AB"))
+ self.assertFalse(is_valid_number_plate("T12ABC"))
+ self.assertFalse(is_valid_number_plate("1234567"))
+ self.assertFalse(is_valid_number_plate(None))
+
+ def test_decode_tpf_response_passthrough_and_decrypt(self):
+ plain = {"pending_transactions": [fine("REF-X")]}
+ self.assertEqual(decode_tpf_response(plain), plain)
+
+ payload = encrypt_tpf_payload(plain)
+ self.assertEqual(decode_tpf_response({"payload": payload}), plain)
+
+ double_encoded = base64.b64encode(payload.encode()).decode()
+ self.assertEqual(decode_tpf_response({"payload": double_encoded}), plain)
+
+
+class TestVehicleFineSync(IntegrationTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.vehicle = make_vehicle(PLATE)
+ cls.commit_patch = patch.object(frappe.db, "commit")
+ cls.commit_patch.start()
+ cls.addClassCleanup(cls.commit_patch.stop)
+
+ def test_invalid_plates_are_rejected_without_calling_tpf(self):
+ with patch("requests.post") as post:
+ self.assertEqual(sync_vehicle_fines("")["status"], "invalid")
+ self.assertEqual(sync_vehicle_fines("T12")["status"], "invalid")
+ post.assert_not_called()
+
+ def test_sync_creates_marks_paid_and_notifies(self):
+ configure_authority_notifications(
+ ["Vehicle Fine"], vehicle_fine_notify_on_new=1, vehicle_fine_notify_on_status_change=1
+ )
+ with (
+ patch("requests.post", return_value=tpf_response([fine("REF-A"), {"no": "reference"}])),
+ patch("frappe.sendmail") as sendmail,
+ ):
+ result = sync_vehicle_fines("t 444 ddd")
+ self.assertEqual(result["status"], "success")
+ self.assertEqual(result["fine_list"], ["REF-A"])
+ record = frappe.get_doc("Vehicle Fine Record", "REF-A")
+ self.assertEqual(record.vehicle, PLATE)
+ self.assertEqual(record.vehicle_doc, self.vehicle.name)
+ self.assertEqual(record.status, "PENDING")
+ self.assertEqual(record.total, 30000)
+ self.assertIsNotNone(record.authority_notified_on_new)
+ self.assertEqual(record.authority_last_notified_status, "PENDING")
+ self.assertEqual(sendmail.call_count, 1)
+
+ with (
+ patch("requests.post", return_value=tpf_response([fine("REF-A"), fine("REF-B")])),
+ patch("frappe.sendmail") as sendmail,
+ ):
+ self.assertEqual(get_fine(PLATE), ["REF-A", "REF-B"])
+ self.assertTrue(frappe.db.exists("Vehicle Fine Record", "REF-B"))
+ self.assertEqual(sendmail.call_count, 1)
+
+ with (
+ patch("requests.post", return_value=tpf_response([fine("REF-B")])),
+ patch("frappe.sendmail") as sendmail,
+ ):
+ sync_vehicle_fines(PLATE)
+ self.assertEqual(frappe.db.get_value("Vehicle Fine Record", "REF-A", "status"), "PAID")
+ self.assertEqual(
+ frappe.db.get_value("Vehicle Fine Record", "REF-A", "authority_last_notified_status"), "PAID"
+ )
+ self.assertEqual(frappe.db.get_value("Vehicle Fine Record", "REF-B", "status"), "PENDING")
+ sendmail.assert_called_once()
+
+ with patch("requests.post", return_value=tpf_response([])), patch("frappe.sendmail") as sendmail:
+ self.assertEqual(get_fine(PLATE), [])
+ self.assertEqual(frappe.db.get_value("Vehicle Fine Record", "REF-B", "status"), "PAID")
+ sendmail.assert_called_once()
+
+ def test_sync_without_references_returns_success(self):
+ with patch("requests.post", return_value=tpf_response([{"charge": 1}])):
+ result = sync_vehicle_fines(PLATE)
+ self.assertEqual(result["message"], "No fine references")
+
+ def test_rate_limited_and_request_errors(self):
+ with patch("requests.post", return_value=tpf_response([], status_code=429)):
+ self.assertEqual(sync_vehicle_fines(PLATE)["status"], "rate_limited")
+
+ with patch("requests.post", side_effect=requests.exceptions.ConnectionError("down")):
+ result = sync_vehicle_fines(PLATE)
+ self.assertEqual(result["status"], "retryable_error")
+ self.assertEqual(result["message"], "down")
+
+ def test_invalid_json_is_logged(self):
+ response = MagicMock(status_code=200, text="")
+ response.json.side_effect = ValueError("bad json")
+ with patch("requests.post", return_value=response), patch("frappe.log_error") as log_error:
+ result = sync_vehicle_fines(PLATE)
+ self.assertEqual(result["status"], "error")
+ log_error.assert_called_once()
+
+ def test_check_fine_all_vehicles_deduplicates_plates(self):
+ records = [
+ frappe._dict(plate_number="t 555 eee"),
+ frappe._dict(plate_number="T555EEE"),
+ frappe._dict(plate_number="BAD"),
+ frappe._dict(plate_number="T666FFF"),
+ ]
+ with (
+ patch.object(module, "get_vehicle_like_records", return_value=iter(records)),
+ patch.object(module, "get_fine") as get_fine_mock,
+ ):
+ result = check_fine_all_vehicles()
+ self.assertEqual(result["message"], "Processed fine checks for 2 unique vehicle-like records")
+ self.assertEqual(
+ [call.kwargs["number_plate"] for call in get_fine_mock.call_args_list], ["T555EEE", "T666FFF"]
+ )
+
+ def test_send_pending_vehicle_fine_notifications(self):
+ configure_authority_notifications(
+ ["Vehicle Fine"], vehicle_fine_notify_on_new=1, vehicle_fine_notify_on_status_change=1
+ )
+ record = frappe.get_doc(
+ {
+ "doctype": "Vehicle Fine Record",
+ "reference": "REF-PENDING",
+ "vehicle": "T445DDD",
+ "status": "PENDING",
+ }
+ ).insert()
+ frappe.db.set_value("Vehicle Fine Record", record.name, "authority_last_notified_status", "OLD")
+
+ with patch("frappe.sendmail") as sendmail:
+ send_pending_vehicle_fine_notifications()
+ record.reload()
+ self.assertIsNotNone(record.authority_notified_on_new)
+ self.assertEqual(record.authority_last_notified_status, "PENDING")
+ self.assertEqual(sendmail.call_count, 2)
+ for call in sendmail.call_args_list:
+ self.assertEqual(call.kwargs["recipients"], [AUTHORITY_USER])
+
+ def test_validate_clears_vehicle_doc_for_unknown_plate(self):
+ record = frappe.get_doc(
+ {"doctype": "Vehicle Fine Record", "reference": "REF-UNKNOWN", "vehicle": "T777GGG"}
+ ).insert()
+ self.assertIsNone(record.vehicle_doc)
diff --git a/csf_tz/tests/test_vehicle_latra.py b/csf_tz/tests/test_vehicle_latra.py
new file mode 100644
index 00000000..574e8faf
--- /dev/null
+++ b/csf_tz/tests/test_vehicle_latra.py
@@ -0,0 +1,429 @@
+from unittest.mock import patch
+
+import frappe
+import requests
+from frappe.tests import IntegrationTestCase
+from frappe.utils import add_days, nowdate
+
+from csf_tz.csf_tz.doctype.latra_licenses import latra_licenses as module
+from csf_tz.csf_tz.doctype.latra_licenses.latra_licenses import (
+ TOKEN_EXPIRED,
+ notify_latra_license_expiry,
+ send_pending_latra_offence_notifications,
+ sync_all_latra_licenses,
+ update_latra_offences,
+ update_latra_records,
+)
+from csf_tz.tests.integration_fixtures import FakeResponse
+from csf_tz.tests.test_vehicle_authority import AUTHORITY_USER, configure_authority_notifications
+
+PLATES = ["T444LLL", "T555LLL"]
+
+
+def license_row(plate, number, valid_to, license_type="GOODSCARRYINGVEHICLE"):
+ return {
+ "licenseNumber": number,
+ "licenseStatus": "ACTIVE",
+ "validFrom": "2026-01-01T00:00:00",
+ "validTo": valid_to,
+ "serviceType": {"name": "Cargo", "licenseType": {"licenseTypeName": license_type}},
+ "licenseInfoDetail": {
+ "currentLicenseApplication": {"branch": {"name": "Dar", "district": {"districtName": "Ilala"}}}
+ },
+ "vehicle": {"vehicleRegistrationNumber": plate},
+ }
+
+
+def offence_row(plate, reference, status="UNPAID"):
+ return {
+ "uid": f"uid-{reference}",
+ "vehicleRegistrationNumber": plate,
+ "offenceReferenceNumber": reference,
+ "offenderName": "Driver",
+ "offenceDate": "2026-02-01T10:00:00",
+ "paymentStatus": status,
+ "amount": 50000,
+ "clientOffenceType": "WARNING",
+ "warningDescription": "",
+ "offence": {"name": "Overloading", "compoundedAmount": 50000},
+ "offenceLocation": {"name": "Morogoro"},
+ }
+
+
+def license_page(rows):
+ return FakeResponse(200, {"data": {"findMyCurrentLicensesPageable": {"content": rows}}})
+
+
+def offence_page(rows, total=None):
+ return FakeResponse(
+ 200,
+ {"data": {"allMyClientOffencesPageable": {"content": rows, "totalElements": total or len(rows)}}},
+ )
+
+
+def set_latra_settings(username="user@example.com", password="secret", access_token=""):
+ settings = frappe.get_single("Latra Settings")
+ settings.username = username
+ settings.password = password
+ settings.access_token = access_token
+ settings.save(ignore_permissions=True)
+ frappe.cache().delete_value(module._token_cache_key())
+
+
+class TestLatraHelpers(IntegrationTestCase):
+ def test_parse_date(self):
+ self.assertIsNone(module._parse_date(None))
+ self.assertEqual(module._parse_date("2026-01-05T00:00:00"), "2026-01-05")
+ self.assertEqual(module._parse_date("2026-01-05"), "2026-01-05")
+
+ def test_place_issued_and_license_type(self):
+ row = license_row("T444LLL", "L1", "2026-12-31")
+ self.assertEqual(module._get_place_issued(row), "Dar (Ilala)")
+ self.assertEqual(module._get_place_issued({"licenseInfoDetail": {}}), "")
+ self.assertEqual(module._get_license_type(row), "GCV")
+ self.assertEqual(module._get_license_type(license_row("x", "y", "z", "PRIVATEHIRE")), "Private Hire")
+ self.assertEqual(module._get_license_type(license_row("x", "y", "z", "OTHER")), "OTHER")
+
+ def test_log_sync_summary(self):
+ with patch("frappe.log_error") as log_error:
+ module._log_sync_summary({"message": "a"}, "b")
+ log_error.assert_called_once_with(title="LATRA Sync Summary", message="Licenses: a\nOffences: b")
+
+
+class TestLatraToken(IntegrationTestCase):
+ def setUp(self):
+ set_latra_settings()
+
+ def test_get_token_reads_settings_and_caches(self):
+ self.assertEqual(module._get_token(), "")
+ set_latra_settings(access_token=" tok ")
+ self.assertEqual(module._get_token(), "tok")
+ frappe.db.set_single_value("Latra Settings", "access_token", "other")
+ self.assertEqual(module._get_token(), "tok")
+
+ def test_get_token_logs_errors(self):
+ with (
+ patch("frappe.db.get_single_value", side_effect=RuntimeError("db")),
+ patch("frappe.log_error") as log,
+ ):
+ self.assertIsNone(module._get_token())
+ log.assert_called_once()
+
+ def test_refresh_token_without_credentials(self):
+ frappe.db.set_single_value("Latra Settings", "username", "")
+ with patch("frappe.log_error") as log_error:
+ self.assertIsNone(module._refresh_token_locked())
+ self.assertEqual(log_error.call_args.kwargs["title"], "LATRA: Credentials missing")
+
+ def test_refresh_token_success(self):
+ response = FakeResponse(200, {"data": {"accessToken": " new-token "}})
+ with patch("requests.post", return_value=response) as post:
+ self.assertEqual(module._refresh_token(), "new-token")
+ self.assertEqual(
+ post.call_args.kwargs["data"], {"username": "user@example.com", "password": "secret"}
+ )
+ self.assertEqual(frappe.db.get_single_value("Latra Settings", "access_token"), "new-token")
+ self.assertEqual(module._get_token(), "new-token")
+
+ def test_refresh_token_failures(self):
+ with patch("requests.post", return_value=FakeResponse(401, {}, text="denied")):
+ self.assertIsNone(module._refresh_token_locked())
+ with patch("requests.post", return_value=FakeResponse(200, {"data": {}})):
+ self.assertIsNone(module._refresh_token_locked())
+ with patch("requests.post", side_effect=requests.exceptions.ConnectionError("down")):
+ self.assertIsNone(module._refresh_token_locked())
+
+ def test_refresh_token_reuses_token_refreshed_by_another_worker(self):
+ set_latra_settings(access_token="fresh")
+ with patch("requests.post") as post:
+ self.assertEqual(module._refresh_token(old_token="stale"), "fresh")
+ post.assert_not_called()
+
+
+class TestLatraApiCalls(IntegrationTestCase):
+ def test_call_license_page_statuses(self):
+ with patch("requests.post", return_value=license_page([{"licenseNumber": "L1"}])):
+ self.assertEqual(
+ module._call_license_page("tok"),
+ {"findMyCurrentLicensesPageable": {"content": [{"licenseNumber": "L1"}]}},
+ )
+ with patch("requests.post", return_value=FakeResponse(401, {})):
+ self.assertEqual(module._call_license_page("tok"), TOKEN_EXPIRED)
+ with patch("requests.post", return_value=FakeResponse(429, {})):
+ self.assertEqual(module._call_license_page("tok"), "RATE_LIMITED")
+ with patch("requests.post", return_value=FakeResponse(500, {})):
+ self.assertIsNone(module._call_license_page("tok"))
+ with patch("requests.post", return_value=FakeResponse(400, {})), patch("frappe.log_error") as log:
+ self.assertIsNone(module._call_license_page("tok"))
+ log.assert_called_once()
+ with patch("requests.post", side_effect=requests.exceptions.Timeout()):
+ self.assertIsNone(module._call_license_page("tok"))
+ with patch("requests.post", return_value=FakeResponse(200, {"errors": [{"message": "bad"}]})):
+ self.assertIsNone(module._call_license_page("tok"))
+ with patch("requests.post", return_value=FakeResponse(200, None, text="")):
+ self.assertIsNone(module._call_license_page("tok"))
+
+ def test_fetch_all_licenses_paginates(self):
+ first = [license_row("T444LLL", f"L{i}", "2026-12-31") for i in range(200)]
+ second = [license_row("T555LLL", "L200", "2026-12-31")]
+ with patch("requests.post", side_effect=[license_page(first), license_page(second)]) as post:
+ rows = module._fetch_all_licenses("tok")
+ self.assertEqual(len(rows), 201)
+ self.assertEqual(post.call_count, 2)
+ self.assertEqual(post.call_args_list[1].kwargs["json"]["variables"]["pageableParam"]["first"], 1)
+
+ with patch("requests.post", return_value=FakeResponse(401, {})):
+ self.assertEqual(module._fetch_all_licenses("tok"), TOKEN_EXPIRED)
+
+ def test_call_offences_page_statuses(self):
+ with patch("requests.post", return_value=offence_page([offence_row("T444LLL", "R1")])):
+ result = module._call_offences_graphql_page("tok")
+ self.assertEqual(result["allMyClientOffencesPageable"]["totalElements"], 1)
+ with patch("requests.post", return_value=FakeResponse(401, {})):
+ self.assertEqual(module._call_offences_graphql_page("tok"), TOKEN_EXPIRED)
+ with patch("requests.post", return_value=FakeResponse(429, {})):
+ self.assertEqual(module._call_offences_graphql_page("tok"), "RATE_LIMITED")
+ with patch("requests.post", return_value=FakeResponse(503, {})):
+ self.assertIsNone(module._call_offences_graphql_page("tok"))
+ with patch("requests.post", return_value=FakeResponse(403, {})):
+ self.assertIsNone(module._call_offences_graphql_page("tok"))
+ with patch("requests.post", side_effect=requests.exceptions.ConnectionError()):
+ self.assertIsNone(module._call_offences_graphql_page("tok"))
+ with patch("requests.post", side_effect=requests.exceptions.InvalidURL()):
+ self.assertIsNone(module._call_offences_graphql_page("tok"))
+ with patch("requests.post", return_value=FakeResponse(200, {"errors": ["x"]})):
+ self.assertIsNone(module._call_offences_graphql_page("tok"))
+ with patch("requests.post", return_value=FakeResponse(200, None, text="oops")):
+ self.assertIsNone(module._call_offences_graphql_page("tok"))
+
+ def test_fetch_all_offences_paginates(self):
+ first = [offence_row("T444LLL", f"R{i}") for i in range(500)]
+ second = [offence_row("T444LLL", "R500")]
+ with patch("requests.post", side_effect=[offence_page(first, 501), offence_page(second, 501)]):
+ rows = module._fetch_all_offences("tok")
+ self.assertEqual(len(rows), 501)
+ with patch("requests.post", return_value=offence_page([])):
+ self.assertEqual(module._fetch_all_offences("tok"), [])
+ with patch("requests.post", return_value=FakeResponse(429, {})):
+ self.assertEqual(module._fetch_all_offences("tok"), "RATE_LIMITED")
+
+
+class TestLatraLicenseSync(IntegrationTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.commit_patch = patch.object(frappe.db, "commit")
+ cls.commit_patch.start()
+ cls.addClassCleanup(cls.commit_patch.stop)
+ cls.plates_patch = patch.object(module, "get_unique_vehicle_plates", return_value=list(PLATES))
+ cls.plates_patch.start()
+ cls.addClassCleanup(cls.plates_patch.stop)
+
+ def setUp(self):
+ set_latra_settings(access_token="tok")
+
+ def test_sync_all_latra_licenses_upserts_latest_license(self):
+ rows = [
+ license_row("t 444 lll", "LIC-OLD", "2026-06-30"),
+ license_row("T444LLL", "LIC-NEW", "2027-06-30"),
+ license_row("T999ZZZ", "LIC-OTHER", "2027-06-30"),
+ {"licenseNumber": "LIC-NOVEHICLE"},
+ ]
+ with patch("requests.post", return_value=license_page(rows)):
+ result = sync_all_latra_licenses("tok")
+ self.assertEqual(
+ (result["matched"], result["skipped"], result["saved"], result["processed"]), (1, 1, 1, 2)
+ )
+ self.assertTrue(result["completed_cycle"])
+ license_doc = frappe.get_doc("Latra Licenses", "LIC-NEW")
+ self.assertEqual(license_doc.vehicle, "T444LLL")
+ self.assertEqual(license_doc.license_type, "GCV")
+ self.assertEqual(license_doc.service_type, "Cargo")
+ self.assertEqual(license_doc.place_issued, "Dar (Ilala)")
+ self.assertEqual(str(license_doc.expire_date), "2027-06-30")
+ self.assertFalse(frappe.db.exists("Latra Licenses", "LIC-OLD"))
+
+ rows[1]["licenseStatus"] = "EXPIRED"
+ with patch("requests.post", return_value=license_page(rows)):
+ result = sync_all_latra_licenses("tok")
+ self.assertEqual(result["saved"], 1)
+ self.assertEqual(frappe.db.get_value("Latra Licenses", "LIC-NEW", "license_status"), "EXPIRED")
+
+ def test_sync_all_latra_licenses_propagates_api_state(self):
+ with patch("requests.post", return_value=FakeResponse(401, {})):
+ self.assertEqual(sync_all_latra_licenses("tok"), TOKEN_EXPIRED)
+ with patch("requests.post", return_value=FakeResponse(500, {})):
+ self.assertIsNone(sync_all_latra_licenses("tok"))
+
+ def test_upsert_latra_license_edge_cases(self):
+ self.assertEqual(module._upsert_latra_license("T444LLL", {}), 0)
+ with patch("frappe.db.exists", side_effect=RuntimeError("db")), patch("frappe.log_error") as log:
+ self.assertEqual(module._upsert_latra_license("T444LLL", {"licenseNumber": "X"}), 0)
+ log.assert_called_once()
+
+ def test_update_latra_records_flow(self):
+ set_latra_settings(access_token="")
+ with patch.object(module, "_refresh_token", return_value=None):
+ self.assertIn("no token", update_latra_records()["message"])
+
+ set_latra_settings(access_token="tok")
+ with patch(
+ "requests.post", return_value=license_page([license_row("T444LLL", "LIC-A", "2027-01-01")])
+ ):
+ result = update_latra_records()
+ self.assertEqual(result["licenses"]["saved"], 1)
+
+ with (
+ patch.object(module, "sync_all_latra_licenses", return_value=TOKEN_EXPIRED),
+ patch.object(module, "_refresh_token", return_value=None),
+ ):
+ self.assertIn("token refresh failed", update_latra_records()["message"])
+ with (
+ patch.object(module, "sync_all_latra_licenses", return_value=TOKEN_EXPIRED),
+ patch.object(module, "_refresh_token", return_value="tok2"),
+ ):
+ self.assertIn("auth failure", update_latra_records()["message"])
+ with patch.object(module, "sync_all_latra_licenses", return_value="RATE_LIMITED"):
+ self.assertIn("throttling", update_latra_records()["message"])
+
+ def test_notify_latra_license_expiry(self):
+ configure_authority_notifications(["LATRA License"], latra_license_notify_before_days=7)
+ for number, days in (("LIC-EXPIRED", -1), ("LIC-SOON", 3), ("LIC-FAR", 30)):
+ frappe.get_doc(
+ {
+ "doctype": "Latra Licenses",
+ "license_number": number,
+ "vehicle": "T444LLL",
+ "expire_date": add_days(nowdate(), days),
+ }
+ ).insert()
+ frappe.get_doc({"doctype": "Latra Licenses", "license_number": "LIC-NODATE"}).insert()
+
+ with patch("frappe.sendmail") as sendmail:
+ notify_latra_license_expiry()
+ self.assertEqual(sendmail.call_count, 2)
+ subjects = sorted(call.kwargs["subject"] for call in sendmail.call_args_list)
+ self.assertTrue(subjects[0].startswith("LATRA License Expired"))
+ self.assertTrue(subjects[1].startswith("LATRA License Expiry Reminder"))
+ self.assertTrue(
+ frappe.db.get_value(
+ "Latra Licenses", "LIC-EXPIRED", "authority_last_expiry_notification_key"
+ ).startswith("expired:")
+ )
+ self.assertIsNone(
+ frappe.db.get_value("Latra Licenses", "LIC-FAR", "authority_last_expiry_notification_key")
+ )
+
+ with patch("frappe.sendmail") as sendmail:
+ notify_latra_license_expiry()
+ sendmail.assert_not_called()
+
+
+class TestLatraOffenceSync(IntegrationTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.commit_patch = patch.object(frappe.db, "commit")
+ cls.commit_patch.start()
+ cls.addClassCleanup(cls.commit_patch.stop)
+
+ def setUp(self):
+ set_latra_settings(access_token="tok")
+ frappe.db.delete("Latra Offence")
+
+ def test_update_latra_offences_without_plates(self):
+ with patch.object(module, "get_unique_vehicle_plates", return_value=[]):
+ result = update_latra_offences()
+ self.assertEqual(result["processed"], 0)
+ self.assertTrue(result["completed_cycle"])
+
+ def test_update_latra_offences_creates_updates_and_notifies(self):
+ configure_authority_notifications(
+ ["LATRA Offence"], latra_offence_notify_on_new=1, latra_offence_notify_on_status_change=1
+ )
+ rows = [
+ offence_row("t-444-lll", "REF-1"),
+ offence_row("T444LLL", "REF-2"),
+ offence_row("T888ZZZ", "REF-3"),
+ ]
+ with (
+ patch.object(module, "get_unique_vehicle_plates", return_value=list(PLATES)),
+ patch("requests.post", return_value=offence_page(rows)),
+ patch("frappe.sendmail") as sendmail,
+ ):
+ result = update_latra_offences()
+ self.assertEqual(
+ (result["processed"], result["saved"], result["skipped"], result["matched"]), (2, 2, 1, 1)
+ )
+ self.assertEqual(result["fetched_offences"], 3)
+ self.assertEqual(sendmail.call_count, 2)
+ self.assertEqual(sendmail.call_args.kwargs["recipients"], [AUTHORITY_USER])
+
+ offence = frappe.get_doc("Latra Offence", {"reference_number": "REF-1"})
+ self.assertEqual(offence.mv_reg_number, "T444LLL")
+ self.assertEqual(offence.offence, "Overloading")
+ self.assertEqual(offence.location, "Morogoro")
+ self.assertEqual(str(offence.offence_date), "2026-02-01")
+ self.assertEqual(offence.amount, 50000)
+ self.assertEqual(offence.authority_last_notified_status, "UNPAID")
+ self.assertIsNotNone(offence.authority_notified_on_new)
+
+ rows[0]["paymentStatus"] = "PAID"
+ with (
+ patch.object(module, "get_unique_vehicle_plates", return_value=list(PLATES)),
+ patch("requests.post", return_value=offence_page(rows)),
+ patch("frappe.sendmail") as sendmail,
+ ):
+ result = update_latra_offences()
+ self.assertEqual(result["saved"], 2)
+ self.assertEqual(frappe.db.count("Latra Offence"), 2)
+ sendmail.assert_called_once()
+ self.assertIn("from UNPAID to PAID", sendmail.call_args.kwargs["message"])
+ offence.reload()
+ self.assertEqual(offence.status, "PAID")
+ self.assertEqual(offence.authority_last_notified_status, "PAID")
+
+ def test_update_latra_offences_api_failures(self):
+ with patch.object(module, "get_unique_vehicle_plates", return_value=list(PLATES)):
+ with patch("requests.post", return_value=FakeResponse(429, {})):
+ self.assertIn("throttling", update_latra_offences()["message"])
+ with patch("requests.post", return_value=FakeResponse(500, {})), patch("frappe.log_error"):
+ self.assertIn("upstream failure", update_latra_offences()["message"])
+ with (
+ patch("requests.post", return_value=FakeResponse(401, {})),
+ patch.object(module, "_refresh_token", return_value=None),
+ patch("frappe.log_error"),
+ ):
+ self.assertIn("upstream failure", update_latra_offences()["message"])
+ set_latra_settings(access_token="")
+ with patch.object(module, "_refresh_token", return_value=None), patch("frappe.log_error"):
+ self.assertIn("no token", update_latra_offences()["message"])
+
+ def test_send_pending_latra_offence_notifications(self):
+ configure_authority_notifications(
+ ["LATRA Offence"], latra_offence_notify_on_new=1, latra_offence_notify_on_status_change=1
+ )
+ offence = frappe.get_doc(
+ {
+ "doctype": "Latra Offence",
+ "mv_reg_number": "T444LLL",
+ "reference_number": "REF-P",
+ "status": "UNPAID",
+ "amount": 10,
+ }
+ ).insert()
+ frappe.db.set_value("Latra Offence", offence.name, "authority_last_notified_status", "PENDING")
+ with patch("frappe.sendmail") as sendmail:
+ send_pending_latra_offence_notifications()
+ self.assertEqual(sendmail.call_count, 2)
+ offence.reload()
+ self.assertIsNotNone(offence.authority_notified_on_new)
+ self.assertEqual(offence.authority_last_notified_status, "UNPAID")
+
+ def test_notify_latra_offence_disabled_events(self):
+ configure_authority_notifications(["LATRA Offence"])
+ with patch("frappe.sendmail") as sendmail:
+ module._notify_latra_offence("x", {"status": "UNPAID"}, is_new=True)
+ module._notify_latra_offence("x", {"status": "PAID"}, is_new=False, old_status="UNPAID")
+ module._notify_latra_offence("x", {"status": "PAID"}, is_new=False, old_status="PAID")
+ sendmail.assert_not_called()
diff --git a/csf_tz/tests/test_vehicle_sync_task.py b/csf_tz/tests/test_vehicle_sync_task.py
new file mode 100644
index 00000000..51e5d424
--- /dev/null
+++ b/csf_tz/tests/test_vehicle_sync_task.py
@@ -0,0 +1,203 @@
+from unittest.mock import patch
+
+import frappe
+from frappe.tests import IntegrationTestCase
+from frappe.utils import add_to_date, get_datetime, now_datetime
+
+from csf_tz.csf_tz.doctype.vehicle_sync_task import processor, queue
+from csf_tz.csf_tz.doctype.vehicle_sync_task.vehicle_sync_task import VehicleSyncTask
+
+TASK = "Vehicle Sync Task"
+
+
+def task_values(name, *fields):
+ return frappe.db.get_value(TASK, name, list(fields), as_dict=True)
+
+
+class TestVehicleSyncQueue(IntegrationTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.commit_patch = patch.object(frappe.db, "commit")
+ cls.commit_patch.start()
+ cls.addClassCleanup(cls.commit_patch.stop)
+
+ def setUp(self):
+ frappe.db.delete(TASK)
+ frappe.cache().delete_value(processor.RATE_LIMIT_CACHE_KEY)
+
+ def test_create_sync_task_normalizes_and_deduplicates(self):
+ name = processor.create_sync_task("t 100 aaa")
+ values = task_values(name, "vehicle_no", "status", "next_run_at", "priority")
+ self.assertEqual(values.vehicle_no, "T100AAA")
+ self.assertEqual(values.status, "Pending")
+ self.assertIsNone(values.next_run_at)
+
+ self.assertEqual(processor.create_sync_task("T100AAA"), name)
+ self.assertEqual(task_values(name, "priority").priority, 0)
+
+ self.assertEqual(processor.create_sync_task("T100AAA", immediate=True), name)
+ values = task_values(name, "next_run_at", "priority")
+ self.assertIsNotNone(values.next_run_at)
+ self.assertEqual(values.priority, 5)
+
+ self.assertEqual(processor.create_sync_task("T100AAA", priority=9), name)
+ self.assertEqual(task_values(name, "priority").priority, 9)
+
+ def test_create_sync_task_rejects_invalid_plate(self):
+ self.assertIsNone(processor.create_sync_task("BAD"))
+ self.assertIsNone(processor.create_sync_task(None))
+
+ def test_create_sync_task_reactivates_deleted_task(self):
+ name = processor.create_sync_task("T101AAA")
+ frappe.db.set_value(TASK, name, {"is_deleted": 1, "status": "Failed", "last_error": "x"})
+
+ self.assertEqual(processor.create_sync_task("T101AAA", priority=2, immediate=True), name)
+ values = task_values(name, "is_deleted", "status", "priority", "last_error", "next_run_at")
+ self.assertEqual(values.is_deleted, 0)
+ self.assertEqual(values.status, "Pending")
+ self.assertEqual(values.priority, 2)
+ self.assertEqual(values.last_error, "")
+ self.assertIsNotNone(values.next_run_at)
+
+ def test_claim_batch_prefers_priority_and_marks_processing(self):
+ low = processor.create_sync_task("T102AAA", priority=0)
+ high = processor.create_sync_task("T103AAA", priority=3)
+
+ claimed = queue.claim_batch(TASK, limit=1)
+ self.assertEqual([row["name"] for row in claimed], [high])
+ self.assertEqual(claimed[0]["vehicle_no"], "T103AAA")
+ values = task_values(high, "status", "claimed_by", "claimed_at", "last_run_at")
+ self.assertEqual(values.status, "Processing")
+ self.assertEqual(values.claimed_by, queue.WORKER_ID)
+ self.assertIsNotNone(values.claimed_at)
+ self.assertIsNotNone(values.last_run_at)
+
+ self.assertEqual([row["name"] for row in queue.claim_batch(TASK, limit=5)], [low])
+ self.assertEqual(queue.claim_batch(TASK), [])
+
+ def test_claim_batch_skips_future_and_deleted_tasks(self):
+ future = processor.create_sync_task("T104AAA")
+ frappe.db.set_value(TASK, future, "next_run_at", add_to_date(now_datetime(), hours=1))
+ deleted = processor.create_sync_task("T105AAA")
+ frappe.db.set_value(TASK, deleted, "is_deleted", 1)
+ self.assertEqual(queue.claim_batch(TASK, limit=5), [])
+
+ def test_claim_batch_falls_back_to_failed_tasks(self):
+ name = processor.create_sync_task("T106AAA")
+ queue.mark_failed(TASK, {"name": name}, "boom " * 500)
+ values = task_values(name, "status", "last_error", "claimed_by", "next_run_at")
+ self.assertEqual(values.status, "Failed")
+ self.assertEqual(len(values.last_error), 1000)
+ self.assertEqual(values.claimed_by, "")
+ self.assertIsNotNone(values.next_run_at)
+
+ self.assertEqual([row["name"] for row in queue.claim_batch(TASK)], [name])
+
+ def test_mark_done_schedules_next_run(self):
+ name = processor.create_sync_task("T107AAA")
+ queue.claim_batch(TASK)
+ queue.mark_done(TASK, {"name": name})
+ values = task_values(name, "status", "claimed_by", "claimed_at", "next_run_at", "last_error")
+ self.assertEqual(values.status, "Pending")
+ self.assertEqual(values.claimed_by, "")
+ self.assertIsNone(values.claimed_at)
+ self.assertEqual(values.last_error, "")
+ seconds_ahead = (get_datetime(values.next_run_at) - now_datetime()).total_seconds()
+ self.assertGreater(seconds_ahead, queue.SUCCESS_INTERVAL_SECONDS - 120)
+
+ def test_reset_stuck_tasks(self):
+ stuck = processor.create_sync_task("T108AAA")
+ fresh = processor.create_sync_task("T109AAA")
+ frappe.db.set_value(
+ TASK, stuck, {"status": "Processing", "claimed_at": add_to_date(now_datetime(), minutes=-30)}
+ )
+ frappe.db.set_value(TASK, fresh, {"status": "Processing", "claimed_at": now_datetime()})
+
+ self.assertEqual(queue.reset_stuck_tasks(TASK, timeout_minutes=10), 1)
+ self.assertEqual(task_values(stuck, "status", "claimed_by").status, "Pending")
+ self.assertEqual(task_values(fresh, "status").status, "Processing")
+
+ def test_queue_helpers_log_errors_on_bad_doctype(self):
+ with patch("frappe.log_error") as log_error:
+ self.assertEqual(queue.claim_batch("No Such DocType"), [])
+ queue.mark_done("No Such DocType", {"name": "x"})
+ queue.mark_failed("No Such DocType", {"name": "x"}, "err")
+ self.assertEqual(queue.reset_stuck_tasks("No Such DocType"), 0)
+ self.assertEqual(log_error.call_count, 4)
+
+ def test_rate_limit_slot(self):
+ self.assertTrue(processor._acquire_rate_limit_slot())
+ for _ in range(queue.MAX_CALLS_PER_MINUTE - 1):
+ self.assertTrue(processor._acquire_rate_limit_slot())
+ self.assertFalse(processor._acquire_rate_limit_slot())
+
+ def test_run_vehicle_batch_without_tasks(self):
+ self.assertEqual(processor.run_vehicle_batch()["status"], "no_tasks")
+
+ def test_run_vehicle_batch_success_and_failure(self):
+ name = processor.create_sync_task("T110AAA")
+ with patch.object(processor, "sync_vehicle_fines", return_value={"status": "success"}) as sync:
+ result = processor.run_vehicle_batch()
+ sync.assert_called_once_with("T110AAA")
+ self.assertEqual(result, {"status": "completed", "processed": 1, "errors": 0, "claimed": 1})
+ self.assertEqual(task_values(name, "status").status, "Pending")
+
+ frappe.db.set_value(TASK, name, "next_run_at", None)
+ frappe.cache().delete_value(processor.RATE_LIMIT_CACHE_KEY)
+ with patch.object(
+ processor, "sync_vehicle_fines", return_value={"status": "retryable_error", "message": "timeout"}
+ ):
+ result = processor.run_vehicle_batch()
+ self.assertEqual(result["errors"], 1)
+ values = task_values(name, "status", "last_error")
+ self.assertEqual(values.status, "Failed")
+ self.assertEqual(values.last_error, "timeout")
+
+ def test_run_vehicle_batch_respects_rate_limit(self):
+ name = processor.create_sync_task("T111AAA")
+ with (
+ patch.object(processor, "_acquire_rate_limit_slot", return_value=False),
+ patch.object(processor, "sync_vehicle_fines") as sync,
+ ):
+ result = processor.run_vehicle_batch()
+ sync.assert_not_called()
+ self.assertEqual(result["processed"], 0)
+ self.assertIn("limit", task_values(name, "last_error").last_error)
+
+ def test_seed_vehicle_sync_queue(self):
+ def records(plates):
+ return lambda: iter(frappe._dict(plate_number=plate) for plate in plates)
+
+ with patch.object(
+ processor, "get_vehicle_like_records", records(["T120AAA", "t 120 aaa", "T121AAA", "X"])
+ ):
+ result = processor.seed_vehicle_sync_queue()
+ self.assertEqual((result["status"], result["created"], result["total_vehicles"]), ("success", 2, 2))
+
+ with patch.object(processor, "get_vehicle_like_records", records(["T120AAA"])):
+ result = processor.seed_vehicle_sync_queue()
+ self.assertEqual((result["skipped"], result["deleted_marked"]), (1, 1))
+ self.assertEqual(frappe.db.get_value(TASK, {"vehicle_no": "T121AAA"}, "is_deleted"), 1)
+
+ with patch.object(processor, "get_vehicle_like_records", records(["T120AAA", "T121AAA"])):
+ result = processor.seed_vehicle_sync_queue()
+ self.assertEqual((result["skipped"], result["reactivated"]), (1, 1))
+ self.assertEqual(frappe.db.get_value(TASK, {"vehicle_no": "T121AAA"}, "is_deleted"), 0)
+
+ def test_seed_vehicle_sync_queue_reports_errors(self):
+ with (
+ patch.object(processor, "get_vehicle_like_records", side_effect=RuntimeError("meta")),
+ patch("frappe.log_error") as log_error,
+ ):
+ result = processor.seed_vehicle_sync_queue()
+ self.assertEqual(result, {"status": "error", "message": "meta"})
+ log_error.assert_called_once()
+
+ def test_clear_old_logs(self):
+ old = processor.create_sync_task("T130AAA")
+ recent = processor.create_sync_task("T131AAA")
+ frappe.db.set_value(TASK, old, "creation", add_to_date(now_datetime(), days=-10))
+ VehicleSyncTask.clear_old_logs(days=7)
+ self.assertFalse(frappe.db.exists(TASK, old))
+ self.assertTrue(frappe.db.exists(TASK, recent))
diff --git a/csf_tz/tests/test_vehicle_tira_covernote.py b/csf_tz/tests/test_vehicle_tira_covernote.py
new file mode 100644
index 00000000..ce460882
--- /dev/null
+++ b/csf_tz/tests/test_vehicle_tira_covernote.py
@@ -0,0 +1,187 @@
+import json
+from unittest.mock import patch
+
+import frappe
+import requests
+from frappe.tests import IntegrationTestCase
+from frappe.utils import add_days, nowdate
+
+from csf_tz.csf_tz.doctype.tz_insurance_cover_note import tz_insurance_cover_note as module
+from csf_tz.csf_tz.doctype.tz_insurance_cover_note.tz_insurance_cover_note import (
+ fetch_and_update_covernote,
+ get_covernote_details,
+ notify_tira_covernote_expiry,
+ update_covernote_docs,
+)
+from csf_tz.tests.integration_fixtures import FakeResponse
+from csf_tz.tests.test_vehicle_authority import AUTHORITY_USER, configure_authority_notifications
+
+PLATE = "T444TTT"
+JAN_2024 = 1704067200000
+
+
+def tira_record(cover_note_number="CN-001", status="Active"):
+ return {
+ "coverNoteNumber": cover_note_number,
+ "coverNoteStartDate": JAN_2024,
+ "coverNoteEndDate": 1735603200000,
+ "statusTitle": status,
+ "currencyCode": "TZS",
+ "totalPremiumAmountIncludingTax": 150000,
+ "isMotor": True,
+ "motor": {
+ "registrationNumber": PLATE,
+ "make": "Toyota",
+ "createdDate": JAN_2024,
+ "updatedDate": None,
+ },
+ "company": {
+ "companyName": "Insurer Ltd",
+ "incorporationDate": JAN_2024,
+ "shareholders": [{"name": "Owner"}],
+ },
+ "policyHolders": [{"policyHolderFullName": "John Doe", "policyHolderBirthDate": 631152000000}],
+ }
+
+
+class TestGetCovernoteDetails(IntegrationTestCase):
+ def test_success_returns_json(self):
+ with patch("requests.post", return_value=FakeResponse(200, {"data": []})) as post:
+ self.assertEqual(get_covernote_details(PLATE), {"data": []})
+ self.assertEqual(json.loads(post.call_args.kwargs["data"]), {"paramType": 2, "searchParam": PLATE})
+
+ def test_client_error_is_logged_without_retry(self):
+ with (
+ patch("requests.post", return_value=FakeResponse(404, {}, text="missing")) as post,
+ patch("frappe.log_error") as log_error,
+ ):
+ self.assertIsNone(get_covernote_details(PLATE))
+ self.assertEqual(post.call_count, 1)
+ self.assertEqual(log_error.call_args.kwargs["title"], "Tiramis API Error")
+
+ def test_server_errors_and_timeouts_are_retried(self):
+ with (
+ patch("requests.post", return_value=FakeResponse(500, {})) as post,
+ patch.object(module, "sleep"),
+ ):
+ self.assertIsNone(get_covernote_details(PLATE))
+ self.assertEqual(post.call_count, 3)
+
+ with (
+ patch("requests.post", side_effect=requests.exceptions.Timeout()) as post,
+ patch.object(module, "sleep") as sleep,
+ ):
+ self.assertIsNone(get_covernote_details(PLATE))
+ self.assertEqual(post.call_count, 3)
+ self.assertEqual(sleep.call_count, 2)
+
+ with (
+ patch("requests.post", side_effect=requests.exceptions.InvalidURL()),
+ patch("frappe.log_error") as log,
+ ):
+ self.assertIsNone(get_covernote_details(PLATE))
+ log.assert_called_once()
+
+ def test_invalid_json_is_logged(self):
+ with (
+ patch("requests.post", return_value=FakeResponse(200, None, text="")),
+ patch("frappe.log_error") as log_error,
+ ):
+ self.assertIsNone(get_covernote_details(PLATE))
+ self.assertEqual(log_error.call_args.kwargs["title"], "Tiramis API: Invalid JSON")
+
+
+class TestCovernoteSync(IntegrationTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.commit_patch = patch.object(frappe.db, "commit")
+ cls.commit_patch.start()
+ cls.addClassCleanup(cls.commit_patch.stop)
+
+ def test_fetch_and_update_covernote_creates_and_updates(self):
+ with patch.object(module, "get_covernote_details", return_value={"data": [tira_record()]}):
+ fetch_and_update_covernote(PLATE)
+
+ doc = frappe.get_doc("TZ Insurance Cover Note", "CN-001")
+ self.assertEqual(doc.vehicle, PLATE)
+ self.assertEqual(doc.statustitle, "Active")
+ self.assertEqual(doc.covernotestartdate, "2024-01-01 00:00:00")
+ self.assertEqual(doc.covernoteenddate, "2024-12-31 00:00:00")
+ self.assertEqual(doc.totalpremiumamountincludingtax, 150000)
+ self.assertEqual(doc.ismotor, 1)
+ self.assertEqual(len(doc.insurance_motors), 1)
+ self.assertEqual(doc.insurance_motors[0].registrationnumber, PLATE)
+ self.assertEqual(doc.insurance_motors[0].createddate, "2024-01-01 00:00:00")
+ self.assertIsNone(doc.insurance_motors[0].updateddate)
+ self.assertEqual(doc.insurance_provider[0].companyname, "Insurer Ltd")
+ self.assertEqual(doc.insurance_provider[0].incorporationdate, "2024-01-01 00:00:00")
+ self.assertEqual(json.loads(doc.insurance_provider[0].shareholders), [{"name": "Owner"}])
+ self.assertEqual(doc.policy_holders[0].policyholderfullname, "John Doe")
+ self.assertEqual(doc.policy_holders[0].policyholderbirthdate, "1990-01-01 00:00:00")
+
+ with patch.object(
+ module, "get_covernote_details", return_value={"data": [tira_record(status="Expired")]}
+ ):
+ fetch_and_update_covernote(PLATE)
+ doc.reload()
+ self.assertEqual(doc.statustitle, "Expired")
+ self.assertEqual(len(doc.insurance_motors), 1)
+ self.assertEqual(len(doc.policy_holders), 1)
+
+ def test_fetch_and_update_covernote_ignores_empty_and_logs_bad_records(self):
+ with patch.object(module, "get_covernote_details", return_value=None):
+ fetch_and_update_covernote(PLATE)
+ with patch.object(module, "get_covernote_details", return_value={"data": []}):
+ fetch_and_update_covernote(PLATE)
+ with (
+ patch.object(module, "get_covernote_details", return_value={"data": [{"statusTitle": "x"}]}),
+ patch("frappe.log_error") as log_error,
+ ):
+ fetch_and_update_covernote(PLATE)
+ log_error.assert_called_once()
+
+ def test_update_covernote_docs_deduplicates_plates(self):
+ records = [
+ frappe._dict(plate_number="t 444 ttt"),
+ frappe._dict(plate_number="T444TTT"),
+ frappe._dict(plate_number="BAD"),
+ frappe._dict(plate_number="T555TTT"),
+ ]
+ with (
+ patch.object(module, "get_vehicle_like_records", return_value=iter(records)),
+ patch.object(module, "fetch_and_update_covernote") as fetch,
+ ):
+ result = update_covernote_docs()
+ self.assertEqual(result["message"], "Processed covernote updates for 2 vehicles")
+ self.assertEqual([call.args[0] for call in fetch.call_args_list], ["T444TTT", "T555TTT"])
+
+ def test_notify_tira_covernote_expiry(self):
+ configure_authority_notifications(["TIRA"], tira_notify_before_days=7)
+ frappe.db.delete("TZ Insurance Cover Note")
+ for number, days in (("CN-EXPIRED", -1), ("CN-SOON", 3), ("CN-FAR", 30)):
+ frappe.get_doc(
+ {
+ "doctype": "TZ Insurance Cover Note",
+ "covernotenumber": number,
+ "vehicle": PLATE,
+ "covernoteenddate": f"{add_days(nowdate(), days)} 00:00:00",
+ }
+ ).insert()
+ frappe.get_doc({"doctype": "TZ Insurance Cover Note", "covernotenumber": "CN-NODATE"}).insert()
+
+ with patch("frappe.sendmail") as sendmail:
+ notify_tira_covernote_expiry()
+ self.assertEqual(sendmail.call_count, 2)
+ subjects = sorted(call.kwargs["subject"] for call in sendmail.call_args_list)
+ self.assertTrue(subjects[0].startswith("TIRA Cover Note Expired"))
+ self.assertTrue(subjects[1].startswith("TIRA Cover Note Expiry Reminder"))
+ self.assertEqual(sendmail.call_args.kwargs["recipients"], [AUTHORITY_USER])
+ key = frappe.db.get_value(
+ "TZ Insurance Cover Note", "CN-SOON", "authority_last_expiry_notification_key"
+ )
+ self.assertTrue(key.startswith("pre-expiry:"))
+
+ with patch("frappe.sendmail") as sendmail:
+ notify_tira_covernote_expiry()
+ sendmail.assert_not_called()
diff --git a/csf_tz/tests/test_vfd_efd_z_report.py b/csf_tz/tests/test_vfd_efd_z_report.py
new file mode 100644
index 00000000..af19b162
--- /dev/null
+++ b/csf_tz/tests/test_vfd_efd_z_report.py
@@ -0,0 +1,172 @@
+import frappe
+from frappe.tests import IntegrationTestCase
+from frappe.utils import add_days, nowdate
+
+from csf_tz.tests.vfd_test_records import (
+ EXEMPT_TEMPLATE,
+ make_customer,
+ make_item_tax_templates,
+ make_sales_invoice,
+)
+
+FUTURE = f"{add_days(nowdate(), 1)} 00:00:00"
+
+
+def make_device(location="HQ", make="Datecs", model="DP25"):
+ name = f"{location}-{make}-{model}"
+ if frappe.db.exists("Electronic Fiscal Device", name):
+ return frappe.get_doc("Electronic Fiscal Device", name)
+ return frappe.get_doc(
+ {
+ "doctype": "Electronic Fiscal Device",
+ "type": "Electronic Tax Register (ETR)",
+ "serial_no": f"SN-{name}",
+ "location": location,
+ "make": make,
+ "model": model,
+ }
+ ).insert()
+
+
+def make_report(device, z_no="Z1", z_report_date_time=FUTURE, **values):
+ return frappe.get_doc(
+ {
+ "doctype": "EFD Z Report",
+ "electronic_fiscal_device": device.name,
+ "z_no": z_no,
+ "receipts_issued": 1,
+ "z_report_date_time": z_report_date_time,
+ **values,
+ }
+ )
+
+
+def row_for(report, invoice):
+ return next(row for row in report.efd_z_report_invoices if row.invoice_number == invoice.name)
+
+
+class TestElectronicFiscalDevice(IntegrationTestCase):
+ def test_name_is_built_from_location_make_and_model(self):
+ device = make_device("Branch", "Incotex", "M1")
+ self.assertEqual(device.name, "Branch-Incotex-M1")
+ self.assertEqual(device.serial_no, "SN-Branch-Incotex-M1")
+
+
+class TestEFDZReport(IntegrationTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ make_item_tax_templates()
+ make_customer()
+ cls.device = make_device()
+ cls.other_device = make_device("HQ", "Other", "X1")
+ cls.standard_invoice = make_sales_invoice(electronic_fiscal_device=cls.device.name, submit=True)
+ cls.exempt_invoice = make_sales_invoice(item_tax_template=EXEMPT_TEMPLATE, submit=True)
+ cls.other_invoice = make_sales_invoice(electronic_fiscal_device=cls.other_device.name, submit=True)
+
+ def ticked_report(self, **values):
+ report = make_report(self.device, **values)
+ report.get_sales_invoice()
+ row_for(report, self.standard_invoice).include = 1
+ report.update(
+ {
+ "total_turnover": 236,
+ "net_amount": 200,
+ "total_vat": 36,
+ "total_turnover_ex_sr": 0,
+ "total_turnover_ticked": 236,
+ "total_excluding_vat_ticked": 200,
+ "total_vat_ticked": 36,
+ "total_turnover_exempted__sp_relief_ticked": 0,
+ }
+ )
+ return report
+
+ def test_validate_requires_invoices(self):
+ self.assertRaisesRegex(
+ frappe.ValidationError, "No Sales Invoie Found", make_report(self.device).insert
+ )
+
+ def test_get_sales_invoice_fetches_unlinked_invoices_for_device(self):
+ report = make_report(self.device)
+ self.assertTrue(report.get_sales_invoice())
+ fetched = {row.invoice_number for row in report.efd_z_report_invoices}
+ self.assertIn(self.standard_invoice.name, fetched)
+ self.assertIn(self.exempt_invoice.name, fetched)
+ self.assertNotIn(self.other_invoice.name, fetched)
+
+ standard = row_for(report, self.standard_invoice)
+ self.assertEqual(standard.amt_excl_vat, 200)
+ self.assertEqual(standard.vat, 36)
+ self.assertEqual(standard.amt_ex__sr, 0)
+ self.assertEqual(standard.invoice_amount, 236)
+ self.assertEqual(standard.invoice_currency, "INR")
+ self.assertEqual(str(standard.invoice_date), nowdate())
+
+ exempt = row_for(report, self.exempt_invoice)
+ self.assertEqual(exempt.vat, 0)
+ self.assertEqual(exempt.amt_ex__sr, 200)
+
+ def test_get_sales_invoice_respects_report_time(self):
+ report = make_report(self.device, z_report_date_time="2000-01-01 00:00:00")
+ self.assertRaisesRegex(frappe.ValidationError, "No Sales Invoice Fetch", report.get_sales_invoice)
+
+ def test_get_number_of_ticked(self):
+ report = self.ticked_report()
+ self.assertEqual(report.get_number_of_ticked(), 1)
+ row_for(report, self.exempt_invoice).include = 1
+ self.assertEqual(report.get_number_of_ticked(), 2)
+
+ def test_submit_links_included_invoices_and_cancel_unlinks(self):
+ report = self.ticked_report()
+ report.insert()
+ self.assertEqual(report.name, f"{self.device.name}-Z1")
+ report.submit()
+ self.assertEqual(len(report.efd_z_report_invoices), 1)
+ self.assertEqual(
+ frappe.db.get_value("Sales Invoice", self.standard_invoice.name, "efd_z_report"), report.name
+ )
+ self.assertFalse(frappe.db.get_value("Sales Invoice", self.exempt_invoice.name, "efd_z_report"))
+
+ second = make_report(self.device, z_no="Z2")
+ second.get_sales_invoice()
+ fetched = {row.invoice_number for row in second.efd_z_report_invoices}
+ self.assertNotIn(self.standard_invoice.name, fetched)
+
+ report.cancel()
+ self.assertFalse(frappe.db.get_value("Sales Invoice", self.standard_invoice.name, "efd_z_report"))
+
+ def test_submit_throws_when_totals_do_not_match(self):
+ checks = {
+ "total_turnover": "not equal to Money Entered",
+ "net_amount": "Total Excluding VAT",
+ "total_vat": "Total VAT",
+ "total_turnover_ex_sr": "Sp. Relief",
+ }
+ for fieldname, message in checks.items():
+ report = self.ticked_report(z_no=f"Z-{fieldname}")
+ report.set(fieldname, report.get(fieldname) + 5)
+ report.insert()
+ self.assertRaisesRegex(frappe.ValidationError, message, report.submit)
+
+ def test_allowable_difference_tolerates_small_gap(self):
+ report = self.ticked_report(z_no="Z-tol", allowable_difference=10)
+ report.total_turnover = 240
+ report.insert()
+ report.submit()
+ self.assertEqual(report.docstatus, 1)
+ report.cancel()
+
+ def test_submit_throws_when_receipts_issued_mismatch(self):
+ report = self.ticked_report(z_no="Z-count", receipts_issued=2)
+ report.insert()
+ self.assertRaisesRegex(frappe.ValidationError, "Receipts Issued", report.submit)
+
+ def test_submit_throws_when_invoice_already_linked(self):
+ report = self.ticked_report(z_no="Z-linked")
+ report.insert()
+ frappe.db.set_value("Sales Invoice", self.standard_invoice.name, "efd_z_report", "SOMEWHERE-ELSE")
+ self.addCleanup(
+ frappe.db.set_value, "Sales Invoice", self.standard_invoice.name, "efd_z_report", None
+ )
+ self.assertRaisesRegex(frappe.ValidationError, "linked to EFD Z Report SOMEWHERE-ELSE", report.submit)
diff --git a/csf_tz/tests/test_vfd_providers.py b/csf_tz/tests/test_vfd_providers.py
new file mode 100644
index 00000000..3aff17ae
--- /dev/null
+++ b/csf_tz/tests/test_vfd_providers.py
@@ -0,0 +1,591 @@
+import json
+from unittest.mock import patch
+
+import frappe
+from frappe.tests import IntegrationTestCase, UnitTestCase
+from frappe.utils import add_days, add_to_date, get_datetime, now_datetime, nowdate
+
+from csf_tz.tests.vfd_test_records import (
+ COMPANY,
+ EXEMPT_TEMPLATE,
+ ITEM,
+ PROVIDERS,
+ SECOND_ITEM,
+ SERIAL_INFO,
+ fake_response,
+ make_sales_invoice,
+ make_vfd_records,
+ set_company_provider,
+)
+from csf_tz.vfd_providers.doctype.simplify_vfd_settings import simplify_vfd_settings as simplify
+from csf_tz.vfd_providers.doctype.total_vfd_setting import total_vfd_setting as total_vfd
+from csf_tz.vfd_providers.doctype.vfdplus_settings import vfdplus_settings as vfdplus
+from csf_tz.vfd_providers.utils import get_vat_amount
+from csf_tz.vfd_support import utils as vfd_utils
+
+VFDPLUS_REQUEST = "csf_tz.vfd_providers.doctype.vfdplus_settings.vfdplus_settings.requests.request"
+VFDPLUS_SLEEP = "csf_tz.vfd_providers.doctype.vfdplus_settings.vfdplus_settings.sleep"
+TOTAL_REQUEST = "csf_tz.vfd_providers.doctype.total_vfd_setting.total_vfd_setting.requests.request"
+TOTAL_SLEEP = "csf_tz.vfd_providers.doctype.total_vfd_setting.total_vfd_setting.sleep"
+SIMPLIFY_REQUEST = "csf_tz.vfd_providers.doctype.simplify_vfd_settings.simplify_vfd_settings.requests.request"
+SIMPLIFY_SLEEP = "csf_tz.vfd_providers.doctype.simplify_vfd_settings.simplify_vfd_settings.sleep"
+
+PLUS_RECEIPT = {
+ "msg_status": "OK",
+ "msg_code": 2000,
+ "msg_data": {"rctvnum": "ABC123", "idate": "2026-08-25", "itime": "10:20:30"},
+}
+TOTAL_RECEIPT = {
+ "status": 200,
+ "rctvnum": "778899",
+ "verificationLink": "https://verify.tra.go.tz/778899_102030",
+ "localDate": "2026-08-25",
+ "localTime": "10:20:30",
+}
+SIMPLIFY_RECEIPT = {
+ "success": True,
+ "issuedAt": "2026-08-25 10:20:30",
+ "verificationUrl": "https://verify.tra.go.tz/SIM123_102030",
+ "verificationCode": "SIM123",
+ "invoiceId": "inv-1",
+}
+
+
+def posting_doc(invoice):
+ invoice.reload()
+ return frappe.get_doc("VFD Provider Posting", invoice.vfd_posting_info)
+
+
+def set_setting(doctype, fieldname, value):
+ frappe.db.set_value(doctype, COMPANY, fieldname, value)
+ frappe.clear_document_cache(doctype, COMPANY)
+
+
+class TestGetVatAmount(UnitTestCase):
+ def test_exclusive_standard_rate_adds_vat(self):
+ item = frappe._dict(base_net_amount=100, base_amount=100)
+ self.assertEqual(get_vat_amount(item, "A", precision=2), 118.0)
+ self.assertEqual(get_vat_amount(item, "1"), 118.0)
+
+ def test_inclusive_and_non_standard_use_base_amount(self):
+ inclusive = frappe._dict(base_net_amount=84.75, base_amount=100)
+ self.assertEqual(get_vat_amount(inclusive, "A", precision=2), 100)
+ self.assertEqual(get_vat_amount(inclusive, "A"), 100)
+ exempt = frappe._dict(base_net_amount=100, base_amount=100)
+ self.assertEqual(get_vat_amount(exempt, "E", precision=2), 100)
+ self.assertEqual(get_vat_amount(exempt, "E"), 100)
+
+ def test_distributed_discount_counts_as_exclusive(self):
+ item = frappe._dict(base_net_amount=90, base_amount=100, distributed_discount_amount=10)
+ self.assertEqual(get_vat_amount(item, "A", precision=2), 118.0)
+
+
+class TestVFDPlusProvider(IntegrationTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ make_vfd_records()
+ set_company_provider("VFDPlus")
+
+ def test_settings_validate_loads_serial_info(self):
+ settings = frappe.get_doc("VFDPlus Settings", COMPANY)
+ self.assertEqual(settings.serial_id, "SER-1")
+ self.assertEqual(settings.serial_code, "CRED-1")
+ self.assertEqual(settings.vat_enabled, 1)
+ self.assertIn("SER-1", settings.response)
+ body = dict(SERIAL_INFO, msg_data=dict(SERIAL_INFO["msg_data"], tin="555"))
+ with patch(VFDPLUS_REQUEST, return_value=fake_response(body)) as request:
+ settings.save()
+ self.assertEqual(settings.tin, "555")
+ self.assertIn("555", settings.response)
+ kwargs = request.call_args.kwargs
+ self.assertEqual(kwargs["method"], "GET")
+ self.assertEqual(kwargs["url"], "https://vfdplus.test/api/serial")
+ self.assertEqual(kwargs["headers"]["VFDPLUS-API-KEY"], "plus-key")
+
+ def test_get_serial_info_saves_outside_validate(self):
+ settings = frappe.get_doc("VFDPlus Settings", COMPANY)
+ body = dict(SERIAL_INFO, msg_data=dict(SERIAL_INFO["msg_data"], vrn="VRN-9"))
+ with patch(VFDPLUS_REQUEST, return_value=fake_response(body)):
+ vfdplus.get_serial_info(settings, method="on_update")
+ self.assertEqual(frappe.db.get_value("VFDPlus Settings", COMPANY, "vrn"), "VRN-9")
+
+ def test_get_account_info(self):
+ body = {"msg_status": "OK", "msg_code": 2000, "msg_data": {"account_id": "acc-1"}}
+ with patch(VFDPLUS_REQUEST, return_value=fake_response(body)) as request:
+ self.assertEqual(vfdplus.get_account_info(COMPANY), body)
+ self.assertEqual(request.call_args.kwargs["url"], "https://vfdplus.test/api/account")
+
+ def test_get_payload(self):
+ invoice = make_sales_invoice(submit=True)
+ payload = vfdplus.get_payload(invoice)
+ self.assertEqual(payload["credential_code"], "CRED-1")
+ self.assertEqual(payload["trans_no"], invoice.name)
+ self.assertEqual(payload["customer_info"]["cust_id"], "123456789")
+ self.assertEqual(payload["customer_info"]["cust_id_type"], "1- TIN")
+ self.assertEqual(payload["payment_methods"], [{"pmt_type": "INVOICE", "pmt_amount": 236.0}])
+ self.assertEqual(payload["cart_totals"]["total_amount"], 236.0)
+ self.assertEqual(payload["cart_totals"]["item_counts"], 1)
+ item = payload["cart_items"][0]
+ self.assertEqual(item["vat_rate_code"], "A")
+ self.assertEqual(item["vat_rate_id"], "1")
+ self.assertEqual(item["item_qty"], 2)
+ self.assertEqual(item["sp"], 236.0)
+ self.assertEqual(item["usp"], 118.0)
+ self.assertEqual(payload["user_info"]["username"], "Administrator")
+
+ def test_get_payload_exempt_item(self):
+ invoice = make_sales_invoice(item_tax_template=EXEMPT_TEMPLATE, submit=True)
+ payload = vfdplus.get_payload(invoice)
+ self.assertEqual(payload["cart_items"][0]["vat_rate_code"], "E")
+ self.assertEqual(payload["cart_items"][0]["sp"], 200.0)
+
+ def test_autogenerate_on_submit_posts_receipt(self):
+ with patch(VFDPLUS_REQUEST, return_value=fake_response(PLUS_RECEIPT)) as request:
+ invoice = make_sales_invoice(is_auto_generate_vfd=1, submit=True)
+ request.assert_called_once()
+ kwargs = request.call_args.kwargs
+ self.assertEqual(kwargs["method"], "POST")
+ self.assertEqual(kwargs["url"], "https://vfdplus.test/api/receipt")
+ self.assertEqual(json.loads(kwargs["data"])["trans_no"], invoice.name)
+
+ invoice.reload()
+ self.assertEqual(invoice.vfd_status, "Success")
+ self.assertEqual(invoice.vfd_rctvnum, "ABC123")
+ self.assertEqual(invoice.vfd_verification_url, "https://verify.tra.go.tz/ABC123_102030")
+ self.assertEqual(str(invoice.vfd_date), "2026-08-25")
+ self.assertEqual(str(invoice.vfd_time), "10:20:30")
+
+ posting = posting_doc(invoice)
+ self.assertEqual(posting.sales_invoice, invoice.name)
+ self.assertEqual(posting.ackcode, 2000)
+ self.assertIn("ABC123", posting.ackmsg)
+ self.assertIn(invoice.name, posting.req_data)
+ self.assertIn("plus-key", posting.req_headers)
+
+ def test_generate_tra_vfd_post_updates_database(self):
+ invoice = make_sales_invoice(submit=True)
+ self.assertEqual(invoice.vfd_status, "Not Sent")
+ with (
+ patch(VFDPLUS_REQUEST, return_value=fake_response(PLUS_RECEIPT)),
+ patch.object(frappe.db, "commit"),
+ ):
+ result = vfd_utils.generate_tra_vfd(invoice.name)
+ self.assertEqual(result["vfd_provider"], "VFDPlus")
+ self.assertFalse(result["preview"])
+ invoice.reload()
+ self.assertEqual(invoice.vfd_status, "Success")
+ self.assertEqual(invoice.vfd_rctvnum, "ABC123")
+ self.assertEqual(posting_doc(invoice).ackcode, 2000)
+
+ def test_generate_tra_vfd_preview_returns_payload(self):
+ set_setting("VFDPlus Settings", "enable_vfd_preview", 1)
+ self.addCleanup(set_setting, "VFDPlus Settings", "enable_vfd_preview", 0)
+ invoice = make_sales_invoice(submit=True)
+ with patch(VFDPLUS_REQUEST) as request:
+ result = vfd_utils.generate_tra_vfd(invoice.name)
+ request.assert_not_called()
+ self.assertTrue(result["preview"])
+ self.assertEqual(result["data"]["trans_no"], invoice.name)
+ with (
+ patch(VFDPLUS_REQUEST, return_value=fake_response(PLUS_RECEIPT)),
+ patch.object(frappe.db, "commit"),
+ ):
+ result = vfd_utils.generate_tra_vfd(invoice.name, caller="Scheduler")
+ self.assertFalse(result["preview"])
+
+ def test_generate_tra_vfd_skips_non_vfd_invoices(self):
+ invoice = make_sales_invoice(
+ is_not_vfd_invoice=1, item_tax_template=None, with_taxes=False, submit=True
+ )
+ self.assertIsNone(vfd_utils.generate_tra_vfd(invoice.name))
+ invoice = make_sales_invoice(submit=True)
+ invoice.db_set("vfd_status", "Success")
+ self.assertIsNone(vfd_utils.generate_tra_vfd(invoice.name))
+
+ def test_generate_tra_vfd_checks_start_date(self):
+ invoice = make_sales_invoice(submit=True)
+ set_setting("VFDPlus Settings", "vfd_start_date", None)
+ self.addCleanup(set_setting, "VFDPlus Settings", "vfd_start_date", add_days(nowdate(), -30))
+ self.assertRaisesRegex(
+ frappe.ValidationError, "VFD Start Date", vfd_utils.generate_tra_vfd, invoice.name
+ )
+ set_setting("VFDPlus Settings", "vfd_start_date", add_days(nowdate(), 1))
+ self.assertRaisesRegex(
+ frappe.ValidationError, "cannot be generated", vfd_utils.generate_tra_vfd, invoice.name
+ )
+
+ def test_unsupported_provider_throws(self):
+ frappe.get_doc(
+ {
+ "doctype": "VFD Provider",
+ "vfd_provider": "OtherVFD",
+ "vfd_provider_settings": "VFDPlus Settings",
+ }
+ ).insert()
+ set_company_provider("OtherVFD")
+ self.addCleanup(set_company_provider, "VFDPlus")
+ invoice = make_sales_invoice(submit=True)
+ self.assertRaisesRegex(
+ frappe.ValidationError, "not supported", vfd_utils.generate_tra_vfd, invoice.name
+ )
+ set_setting("VFDPlus Settings", "enable_vfd_preview", 1)
+ self.addCleanup(set_setting, "VFDPlus Settings", "enable_vfd_preview", 0)
+ self.assertRaisesRegex(
+ frappe.ValidationError, "not supported", vfd_utils.generate_tra_vfd, invoice.name
+ )
+
+ def test_api_error_status_throws_after_retries(self):
+ invoice = make_sales_invoice(submit=True)
+ body = {"msg_status": "ERROR", "msg_code": 4001, "msg_data": {}}
+ with (
+ patch(VFDPLUS_REQUEST, return_value=fake_response(body)) as request,
+ patch(VFDPLUS_SLEEP),
+ patch.object(frappe, "log_error") as log_error,
+ ):
+ self.assertRaisesRegex(
+ frappe.ValidationError, "Connection failure", vfdplus.post_fiscal_receipt, invoice
+ )
+ self.assertEqual(request.call_count, 3)
+ self.assertIn("Error returned from VFDPlus: 4001", log_error.call_args.kwargs["message"])
+ self.assertEqual(frappe.db.get_value("Sales Invoice", invoice.name, "vfd_status"), "Not Sent")
+
+ def test_http_error_throws(self):
+ invoice = make_sales_invoice(submit=True)
+ response = fake_response({"detail": "bad"}, status_code=500, ok=False)
+ with patch(VFDPLUS_REQUEST, return_value=response), patch(VFDPLUS_SLEEP):
+ self.assertRaisesRegex(
+ frappe.ValidationError,
+ "Connection failure",
+ vfdplus.post_fiscal_receipt,
+ invoice_id=invoice.name,
+ )
+
+ def test_warning_already_posted_is_accepted(self):
+ invoice = make_sales_invoice(submit=True)
+ body = dict(PLUS_RECEIPT, msg_status="WARNING", msg_code=4015)
+ with patch(VFDPLUS_REQUEST, return_value=fake_response(body)), patch.object(frappe.db, "commit"):
+ result = vfdplus.post_fiscal_receipt(invoice_id=invoice.name, method="POST")
+ self.assertEqual(result["data"]["msg_code"], 4015)
+ self.assertEqual(frappe.db.get_value("Sales Invoice", invoice.name, "vfd_rctvnum"), "ABC123")
+
+ def test_post_fiscal_receipt_requires_invoice(self):
+ self.assertRaisesRegex(
+ frappe.ValidationError, "Sales Invoice is required", vfdplus.post_fiscal_receipt
+ )
+ self.assertRaisesRegex(
+ frappe.ValidationError, "Sales Invoice is required", total_vfd.post_fiscal_receipt
+ )
+ self.assertRaisesRegex(
+ frappe.ValidationError, "Sales Invoice is required", simplify.post_fiscal_receipt
+ )
+
+
+class TestTotalVFDProvider(IntegrationTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ make_vfd_records()
+ set_company_provider("TotalVFD")
+
+ def test_get_payload(self):
+ invoice = make_sales_invoice(
+ items=[
+ {"item_code": ITEM, "qty": 2, "rate": 100},
+ {"item_code": SECOND_ITEM, "qty": 1, "rate": 50},
+ ],
+ submit=True,
+ )
+ payload = total_vfd.get_payload(invoice)
+ self.assertEqual(payload["serial"], "TOTAL-SERIAL")
+ self.assertEqual(payload["referenceNumber"], invoice.name)
+ self.assertEqual(payload["customer"]["idType"], "1")
+ self.assertEqual(payload["customer"]["idValue"], "123456789")
+ self.assertEqual(payload["payments"], [{"type": "invoice", "amount": 295.0}])
+ self.assertEqual(len(payload["items"]), 2)
+ self.assertEqual(payload["items"][0]["price"], 236.0)
+ self.assertEqual(payload["items"][0]["vatGroup"], "A")
+ self.assertEqual(payload["items"][1]["price"], 59.0)
+
+ def test_get_payload_grouped_by_vat(self):
+ set_setting("Total VFD Setting", "is_vat_grouped", 1)
+ self.addCleanup(set_setting, "Total VFD Setting", "is_vat_grouped", 0)
+ invoice = make_sales_invoice(
+ items=[
+ {"item_code": ITEM, "qty": 2, "rate": 100},
+ {"item_code": SECOND_ITEM, "qty": 1, "rate": 50},
+ ],
+ submit=True,
+ )
+ payload = total_vfd.get_payload(invoice)
+ self.assertEqual(len(payload["items"]), 1)
+ self.assertEqual(payload["items"][0]["id"], "Items in VAT Group A")
+ self.assertEqual(payload["items"][0]["price"], 295.0)
+ self.assertEqual(payload["items"][0]["qty"], 1)
+
+ def test_autogenerate_on_submit_posts_receipt(self):
+ with patch(TOTAL_REQUEST, return_value=fake_response(TOTAL_RECEIPT)) as request:
+ invoice = make_sales_invoice(is_auto_generate_vfd=1, submit=True)
+ kwargs = request.call_args.kwargs
+ self.assertEqual(kwargs["url"], "https://totalvfd.test/sales")
+ self.assertEqual(kwargs["headers"]["Authorization"], "Bearer total-token")
+ self.assertEqual(kwargs["headers"]["x-active-business"], "business-1")
+ self.assertEqual(json.loads(kwargs["data"])["referenceNumber"], invoice.name)
+
+ invoice.reload()
+ self.assertEqual(invoice.vfd_status, "Success")
+ self.assertEqual(invoice.vfd_rctvnum, "778899")
+ self.assertEqual(invoice.vfd_verification_url, TOTAL_RECEIPT["verificationLink"])
+ self.assertEqual(str(invoice.vfd_date), "2026-08-25")
+ posting = posting_doc(invoice)
+ self.assertEqual(posting.ackcode, 200)
+ self.assertEqual(posting.sales_invoice, invoice.name)
+ self.assertIn("778899", posting.ackmsg)
+ self.assertIn("total-token", posting.req_headers)
+
+ def test_generate_tra_vfd_post_updates_database(self):
+ invoice = make_sales_invoice(submit=True)
+ with (
+ patch(TOTAL_REQUEST, return_value=fake_response(TOTAL_RECEIPT)),
+ patch.object(frappe.db, "commit"),
+ ):
+ result = vfd_utils.generate_tra_vfd(invoice.name)
+ self.assertEqual(result["vfd_provider"], "TotalVFD")
+ invoice.reload()
+ self.assertEqual(invoice.vfd_status, "Success")
+ self.assertEqual(invoice.vfd_rctvnum, "778899")
+ self.assertEqual(posting_doc(invoice).ackcode, 200)
+
+ def test_conflict_response_uses_data_key(self):
+ invoice = make_sales_invoice(submit=True)
+ response = fake_response({"data": TOTAL_RECEIPT}, status_code=409, ok=False)
+ with patch(TOTAL_REQUEST, return_value=response), patch.object(frappe.db, "commit"):
+ result = total_vfd.post_fiscal_receipt(invoice_id=invoice.name)
+ self.assertEqual(result["data"]["rctvnum"], "778899")
+
+ def test_http_error_throws_after_retries(self):
+ invoice = make_sales_invoice(submit=True)
+ response = fake_response({"error": "down"}, status_code=500, ok=False)
+ with patch(TOTAL_REQUEST, return_value=response) as request, patch(TOTAL_SLEEP):
+ self.assertRaisesRegex(
+ frappe.ValidationError, "Connection failure", total_vfd.post_fiscal_receipt, invoice
+ )
+ self.assertEqual(request.call_count, 3)
+
+
+class TestSimplifyVFDProvider(IntegrationTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ make_vfd_records()
+ set_company_provider("SimplifyVFD")
+
+ def settings(self):
+ frappe.clear_document_cache("Simplify VFD Settings", COMPANY)
+ return frappe.get_doc("Simplify VFD Settings", COMPANY)
+
+ def test_get_bearer_token(self):
+ settings = self.settings()
+ body = {"token": "fresh-token", "refresh_token": "fresh-refresh"}
+ with patch(SIMPLIFY_REQUEST, return_value=fake_response(body)) as request:
+ self.assertTrue(settings.get_bearer_token())
+ kwargs = request.call_args.kwargs
+ self.assertEqual(kwargs["url"], "https://simplify.test/login")
+ self.assertEqual(
+ json.loads(kwargs["data"]), {"username": "simplify-user", "password": "simplify-pass"}
+ )
+ self.assertNotIn("Authorization", kwargs["headers"])
+ self.assertEqual(settings.get_password("bearer_token"), "fresh-token")
+ self.assertEqual(settings.get_password("refresh_token"), "fresh-refresh")
+ self.assertGreater(get_datetime(settings.token_expires), now_datetime())
+
+ def test_get_bearer_token_rejects_bad_credentials(self):
+ settings = self.settings()
+ with patch(SIMPLIFY_REQUEST, return_value=fake_response({"message": "denied"})):
+ self.assertRaisesRegex(frappe.ValidationError, "Invalid username", settings.get_bearer_token)
+ settings.username = ""
+ self.assertRaisesRegex(frappe.ValidationError, "Username and Password", settings.get_bearer_token)
+
+ def test_refresh_bearer_token(self):
+ settings = self.settings()
+ stored_refresh_token = settings.get_password("refresh_token")
+ body = {"token": "refreshed", "refresh_token": "refreshed-r"}
+ with patch(SIMPLIFY_REQUEST, return_value=fake_response(body)) as request:
+ self.assertTrue(settings.refresh_bearer_token())
+ self.assertEqual(request.call_args.kwargs["url"], "https://simplify.test/refresh")
+ self.assertEqual(
+ json.loads(request.call_args.kwargs["data"]), {"refresh_token": stored_refresh_token}
+ )
+ self.assertEqual(settings.get_password("bearer_token"), "refreshed")
+ with patch(SIMPLIFY_REQUEST, return_value=fake_response({"token": "x"})):
+ self.assertRaisesRegex(
+ frappe.ValidationError, "Invalid refresh token", settings.refresh_bearer_token
+ )
+
+ def test_refresh_requires_stored_refresh_token(self):
+ settings = self.settings()
+ settings.refresh_token = None
+ self.assertRaisesRegex(
+ frappe.ValidationError, "Refresh Token is not found", settings.refresh_bearer_token
+ )
+
+ def test_get_access_token_refreshes_expired_tokens(self):
+ set_setting("Simplify VFD Settings", "token_expires", add_to_date(now_datetime(), minutes=-1))
+ body = {"token": "sched-token", "refresh_token": "sched-refresh"}
+ with patch(SIMPLIFY_REQUEST, return_value=fake_response(body)) as request:
+ simplify.get_access_token()
+ self.assertEqual(request.call_args.kwargs["url"], "https://simplify.test/refresh")
+ self.assertEqual(self.settings().get_password("bearer_token"), "sched-token")
+ with patch(SIMPLIFY_REQUEST) as request:
+ simplify.get_access_token()
+ request.assert_not_called()
+
+ def test_get_refresh_token_logs_in_again(self):
+ body = {"token": "login-token", "refresh_token": "login-refresh"}
+ with patch(SIMPLIFY_REQUEST, return_value=fake_response(body)) as request:
+ simplify.get_refresh_token()
+ self.assertEqual(request.call_args.kwargs["url"], "https://simplify.test/login")
+ self.assertEqual(self.settings().get_password("refresh_token"), "login-refresh")
+
+ def test_get_payload(self):
+ invoice = make_sales_invoice(submit=True)
+ payload = simplify.get_payload(invoice)
+ self.assertEqual(payload["partnerInvoiceId"], invoice.name)
+ self.assertEqual(payload["invoiceAmountType"], "INCLUSIVE")
+ self.assertEqual(payload["customer"]["identificationType"], "TAX_IDENTIFICATION_NUMBER")
+ self.assertEqual(payload["customer"]["identificationNumber"], "123456789")
+ self.assertEqual(payload["customer"]["name"], invoice.customer_name)
+ self.assertEqual(payload["payments"], [{"type": "INVOICE", "amount": 236.0}])
+ item = payload["items"][0]
+ self.assertEqual(item["description"], ITEM)
+ self.assertEqual(item["quantity"], 2)
+ self.assertEqual(item["unitAmount"], 118.0)
+ self.assertEqual(item["taxType"], "STANDARD")
+
+ def test_get_payload_without_customer_id(self):
+ invoice = make_sales_invoice(submit=True)
+ invoice.vfd_cust_id_type = "6- Other"
+ invoice.vfd_cust_id = "999999999"
+ payload = simplify.get_payload(invoice)
+ self.assertEqual(payload["customer"]["identificationType"], "NO_IDENTIFICATION")
+ self.assertEqual(payload["customer"]["identificationNumber"], "")
+
+ def test_autogenerate_on_submit_posts_receipt(self):
+ with patch(SIMPLIFY_REQUEST, return_value=fake_response(SIMPLIFY_RECEIPT)) as request:
+ invoice = make_sales_invoice(is_auto_generate_vfd=1, submit=True)
+ kwargs = request.call_args.kwargs
+ self.assertEqual(kwargs["url"], "https://simplify.test/invoice")
+ self.assertEqual(kwargs["headers"]["Authorization"], "Bearer simplify-token")
+ self.assertEqual(json.loads(kwargs["data"])["partnerInvoiceId"], invoice.name)
+
+ invoice.reload()
+ self.assertEqual(invoice.vfd_status, "Success")
+ self.assertEqual(invoice.vfd_rctvnum, "SIM123")
+ self.assertEqual(invoice.vfd_verification_url, SIMPLIFY_RECEIPT["verificationUrl"])
+ self.assertEqual(str(invoice.vfd_date), "2026-08-25")
+ self.assertEqual(str(invoice.vfd_time), "10:20:30")
+ posting = posting_doc(invoice)
+ self.assertEqual(posting.ackcode, 200)
+ self.assertIn("SIM123", posting.ackmsg)
+ self.assertIn(invoice.name, posting.req_data)
+ self.assertTrue(
+ frappe.db.exists("Comment", {"reference_name": invoice.name, "content": "VFD Invoice ID: inv-1"})
+ )
+
+ def test_failed_response_marks_invoice_failed(self):
+ body = {"success": False, "message": "rejected"}
+ with patch(SIMPLIFY_REQUEST, return_value=fake_response(body)):
+ invoice = make_sales_invoice(is_auto_generate_vfd=1, submit=True)
+ invoice.reload()
+ self.assertEqual(invoice.vfd_status, "Failed")
+ self.assertFalse(invoice.vfd_rctvnum)
+ self.assertEqual(str(posting_doc(invoice).date), nowdate())
+
+ def test_generate_tra_vfd_post_updates_database(self):
+ invoice = make_sales_invoice(submit=True)
+ with (
+ patch(SIMPLIFY_REQUEST, return_value=fake_response(SIMPLIFY_RECEIPT)),
+ patch.object(frappe.db, "commit"),
+ ):
+ result = vfd_utils.generate_tra_vfd(invoice.name)
+ self.assertEqual(result["vfd_provider"], "SimplifyVFD")
+ invoice.reload()
+ self.assertEqual(invoice.vfd_status, "Success")
+ self.assertEqual(invoice.vfd_rctvnum, "SIM123")
+
+ def test_expired_token_is_refreshed_before_posting(self):
+ set_setting("Simplify VFD Settings", "token_expires", add_to_date(now_datetime(), minutes=-1))
+ invoice = make_sales_invoice(submit=True)
+ responses = [
+ fake_response({"token": "renewed", "refresh_token": "renewed-r"}),
+ fake_response(SIMPLIFY_RECEIPT),
+ ]
+ with patch(SIMPLIFY_REQUEST, side_effect=responses) as request, patch.object(frappe.db, "commit"):
+ simplify.post_fiscal_receipt(invoice_id=invoice.name)
+ self.assertEqual(request.call_count, 2)
+ self.assertEqual(request.call_args_list[0].kwargs["url"], "https://simplify.test/refresh")
+ self.assertEqual(request.call_args_list[1].kwargs["headers"]["Authorization"], "Bearer renewed")
+
+ def test_http_error_throws_after_retries(self):
+ invoice = make_sales_invoice(submit=True)
+ response = fake_response({"error": "down"}, status_code=500, ok=False)
+ with patch(SIMPLIFY_REQUEST, return_value=response) as request, patch(SIMPLIFY_SLEEP):
+ self.assertRaisesRegex(frappe.ValidationError, "Error is", simplify.post_fiscal_receipt, invoice)
+ self.assertEqual(request.call_count, 3)
+
+
+class TestPostingAllVFDInvoices(IntegrationTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ make_vfd_records()
+ set_company_provider("VFDPlus")
+
+ def tearDown(self):
+ frappe.local.flags.vfd_posting = False
+
+ def test_posts_pending_invoices(self):
+ invoice = make_sales_invoice(submit=True)
+ invoice.db_set("vfd_status", "Pending")
+ with (
+ patch(VFDPLUS_REQUEST, return_value=fake_response(PLUS_RECEIPT)) as request,
+ patch.object(frappe.db, "commit"),
+ ):
+ vfd_utils.posting_all_vfd_invoices()
+ self.assertEqual(json.loads(request.call_args.kwargs["data"])["trans_no"], invoice.name)
+ invoice.reload()
+ self.assertEqual(invoice.vfd_status, "Success")
+ self.assertEqual(invoice.vfd_rctvnum, "ABC123")
+ self.assertFalse(frappe.local.flags.vfd_posting)
+
+ def test_skips_sent_and_non_vfd_invoices(self):
+ make_sales_invoice(submit=True)
+ with patch(VFDPLUS_REQUEST) as request:
+ vfd_utils.posting_all_vfd_invoices()
+ request.assert_not_called()
+
+ def test_flag_guard_prevents_parallel_runs(self):
+ frappe.local.flags.vfd_posting = True
+ with patch(VFDPLUS_REQUEST) as request, patch.object(frappe, "log_error") as log_error:
+ vfd_utils.posting_all_vfd_invoices()
+ request.assert_not_called()
+ log_error.assert_called_once()
+
+ def test_posts_with_other_providers(self):
+ for provider, request_path, body in (
+ ("TotalVFD", TOTAL_REQUEST, TOTAL_RECEIPT),
+ ("SimplifyVFD", SIMPLIFY_REQUEST, SIMPLIFY_RECEIPT),
+ ):
+ set_company_provider(provider)
+ self.addCleanup(set_company_provider, "VFDPlus")
+ invoice = make_sales_invoice(submit=True)
+ invoice.db_set("vfd_status", "Failed")
+ with patch(request_path, return_value=fake_response(body)), patch.object(frappe.db, "commit"):
+ vfd_utils.posting_all_vfd_invoices()
+ self.assertEqual(frappe.db.get_value("Sales Invoice", invoice.name, "vfd_status"), "Success")
+
+ def test_provider_records(self):
+ for name, info in PROVIDERS.items():
+ provider = frappe.get_doc("VFD Provider", name)
+ self.assertEqual(provider.vfd_provider_settings, info["settings"])
+ self.assertEqual({row.key: row.value for row in provider.attributes}, info["attributes"])
diff --git a/csf_tz/tests/test_vfd_sales_invoice.py b/csf_tz/tests/test_vfd_sales_invoice.py
new file mode 100644
index 00000000..7f20c9a0
--- /dev/null
+++ b/csf_tz/tests/test_vfd_sales_invoice.py
@@ -0,0 +1,169 @@
+import frappe
+from frappe.tests import IntegrationTestCase
+
+from csf_tz.tests.vfd_test_records import (
+ EXEMPT_TEMPLATE,
+ ITEM,
+ NO_CODE_TEMPLATE,
+ STANDARD_OTHER_TEMPLATE,
+ STANDARD_TEMPLATE,
+ STANDARD_ZERO_TEMPLATE,
+ make_customer,
+ make_item_tax_template,
+ make_item_tax_templates,
+ make_sales_invoice,
+ make_vfd_providers,
+ make_vfdplus_settings,
+)
+from csf_tz.vfd_support import sales_invoice as vfd
+
+
+class TestVFDCustomer(IntegrationTestCase):
+ def test_validate_cleans_tax_id_into_vfd_fields(self):
+ customer = make_customer("_Test VFD TIN Customer", tax_id="123-456-789")
+ self.assertEqual(customer.tax_id, "123456789")
+ self.assertEqual(customer.vfd_cust_id_type, "1- TIN")
+ self.assertEqual(customer.vfd_cust_id, "123456789")
+
+ def test_validate_without_tax_id_uses_other_id_type(self):
+ customer = make_customer("_Test VFD Other Customer", tax_id="")
+ self.assertEqual(customer.tax_id, "")
+ self.assertEqual(customer.vfd_cust_id_type, "6- Other")
+ self.assertEqual(customer.vfd_cust_id, "999999999")
+
+
+class TestVFDSalesInvoiceValidation(IntegrationTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ make_item_tax_templates()
+ make_customer()
+
+ def test_submit_sets_customer_id_from_customer(self):
+ invoice = make_sales_invoice(submit=True)
+ self.assertEqual(invoice.docstatus, 1)
+ self.assertEqual(invoice.vfd_cust_id, "123456789")
+ self.assertEqual(invoice.vfd_cust_id_type, "1- TIN")
+ self.assertEqual(invoice.vfd_status, "Not Sent")
+ self.assertEqual(invoice.base_total_taxes_and_charges, 36)
+
+ def test_exempt_item_with_zero_tax_submits(self):
+ invoice = make_sales_invoice(item_tax_template=EXEMPT_TEMPLATE, submit=True)
+ self.assertEqual(invoice.base_total_taxes_and_charges, 0)
+
+ def test_non_vfd_invoice_skips_validation(self):
+ invoice = make_sales_invoice(
+ item_tax_template=None, with_taxes=False, is_not_vfd_invoice=1, submit=True
+ )
+ self.assertEqual(invoice.docstatus, 1)
+
+ def test_throws_when_net_total_is_zero(self):
+ invoice = make_sales_invoice(items=[{"item_code": ITEM, "qty": 1, "discount_percentage": 100}])
+ self.assertEqual(invoice.base_net_total, 0)
+ self.assertRaisesRegex(frappe.ValidationError, "Base net amount is zero", invoice.submit)
+
+ def test_throws_when_taxes_missing(self):
+ invoice = make_sales_invoice(item_tax_template=None, with_taxes=False)
+ self.assertRaisesRegex(frappe.ValidationError, "Taxes not set correctly", invoice.submit)
+
+ def test_throws_without_item_tax_template(self):
+ invoice = make_sales_invoice(item_tax_template=None)
+ self.assertRaisesRegex(frappe.ValidationError, "Item Taxes Template not set", invoice.submit)
+
+ def test_uses_item_default_tax_template(self):
+ frappe.db.set_value("Item", ITEM, "default_tax_template", STANDARD_TEMPLATE)
+ self.addCleanup(frappe.db.set_value, "Item", ITEM, "default_tax_template", None)
+ invoice = make_sales_invoice(item_tax_template=None, submit=True)
+ self.assertEqual(invoice.items[0].item_tax_template, STANDARD_TEMPLATE)
+
+ def test_throws_for_standard_item_without_18pct_tax(self):
+ invoice = make_sales_invoice(item_tax_template=STANDARD_ZERO_TEMPLATE)
+ self.assertRaisesRegex(frappe.ValidationError, "Standard Rate item", invoice.submit)
+
+ def test_throws_for_other_tax_rate(self):
+ invoice = make_sales_invoice(item_tax_template=STANDARD_OTHER_TEMPLATE)
+ self.assertRaisesRegex(frappe.ValidationError, "Other Tax item", invoice.submit)
+
+ def test_throws_for_exempt_item_with_18pct_tax(self):
+ template = make_item_tax_template("VFD Exempt Wrong - _TC", "5- Exempt", 18)
+ invoice = make_sales_invoice(item_tax_template=template)
+ self.assertRaisesRegex(frappe.ValidationError, "Non Standard Rate item", invoice.submit)
+
+ def test_throws_when_template_has_no_vfd_taxcode(self):
+ invoice = make_sales_invoice(item_tax_template=NO_CODE_TEMPLATE)
+ self.assertRaisesRegex(frappe.ValidationError, "VFD Tax Code not setup", invoice.submit)
+
+ def test_vat_enabled_vfdplus_only_warns_for_standard_item(self):
+ make_vfd_providers()
+ make_vfdplus_settings()
+ frappe.db.set_value("VFDPlus Settings", "_Test Company", "vat_enabled", 1)
+ invoice = make_sales_invoice(item_tax_template=STANDARD_ZERO_TEMPLATE, submit=True)
+ self.assertEqual(invoice.docstatus, 1)
+
+ def test_throws_when_customer_id_type_missing(self):
+ customer = make_customer("_Test VFD No Type Customer")
+ frappe.db.set_value("Customer", customer.name, "vfd_cust_id_type", "")
+ invoice = make_sales_invoice(customer=customer.name)
+ invoice.vfd_cust_id_type = ""
+ self.assertRaisesRegex(frappe.ValidationError, "VFD Customer ID Type", invoice.submit)
+
+ def test_get_customer_id_info_for_customer_without_id(self):
+ customer = make_customer("_Test VFD No ID Customer")
+ frappe.db.set_value("Customer", customer.name, {"vfd_cust_id": "", "vfd_cust_id_type": ""})
+ info = vfd.get_customer_id_info(customer.name)
+ self.assertEqual(info, {"cust_id": "", "cust_id_type": 6, "mobile_no": "2550712345678"})
+
+
+class TestVFDSalesInvoiceCancel(IntegrationTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ make_item_tax_templates()
+ make_customer()
+
+ def test_cancel_blocked_after_vfd_sent(self):
+ invoice = make_sales_invoice(submit=True)
+ invoice.db_set("vfd_rctvnum", "ABC123")
+ invoice.reload()
+ self.assertRaisesRegex(frappe.ValidationError, "already sent to TRA", invoice.cancel)
+
+ def test_cancel_allowed_before_vfd_sent(self):
+ invoice = make_sales_invoice(submit=True)
+ invoice.cancel()
+ self.assertEqual(invoice.docstatus, 2)
+
+
+class TestVFDSalesInvoiceHelpers(IntegrationTestCase):
+ def test_get_item_taxcode_throws_without_template(self):
+ self.assertRaisesRegex(frappe.ValidationError, "Item Taxes Template not set$", vfd.get_item_taxcode)
+ self.assertRaisesRegex(frappe.ValidationError, "for item X$", vfd.get_item_taxcode, item_code="X")
+ self.assertRaisesRegex(
+ frappe.ValidationError,
+ "for item X in invoice INV",
+ vfd.get_item_taxcode,
+ item_code="X",
+ invoice_name="INV",
+ )
+
+ def test_get_item_taxcode_reads_template(self):
+ make_item_tax_templates()
+ self.assertEqual(vfd.get_item_taxcode(STANDARD_TEMPLATE), 1)
+ self.assertEqual(vfd.get_item_taxcode(EXEMPT_TEMPLATE), 5)
+
+ def test_get_item_inclusive_amount(self):
+ exclusive = frappe._dict(base_net_amount=100, base_amount=100, item_tax_rate='{"VAT": 18}')
+ self.assertEqual(vfd.get_item_inclusive_amount(exclusive), 118)
+ zero_rate = frappe._dict(base_net_amount=100, base_amount=100, item_tax_rate='{"VAT": 0}')
+ self.assertEqual(vfd.get_item_inclusive_amount(zero_rate), 100)
+ no_rate = frappe._dict(base_net_amount=100, base_amount=100, item_tax_rate="{}")
+ self.assertEqual(vfd.get_item_inclusive_amount(no_rate), 100)
+ inclusive = frappe._dict(base_net_amount=84.75, base_amount=100, item_tax_rate='{"VAT": 18}')
+ self.assertEqual(vfd.get_item_inclusive_amount(inclusive), 100)
+
+ def test_text_cleaners(self):
+ self.assertEqual(vfd.remove_special_characters("A-1 (b)!"), "A1 b")
+ self.assertEqual(vfd.remove_all_except_numbers("+255 (0)71"), "255071")
+ self.assertEqual(vfd.remove_all_except_numbers(None), "")
+
+ def test_tax_breakup_is_empty_without_taxes(self):
+ self.assertIsNone(vfd.get_itemised_tax_breakup_html(frappe._dict(taxes=[])))
diff --git a/csf_tz/tests/test_vfd_settings_patches.py b/csf_tz/tests/test_vfd_settings_patches.py
new file mode 100644
index 00000000..1fc84791
--- /dev/null
+++ b/csf_tz/tests/test_vfd_settings_patches.py
@@ -0,0 +1,139 @@
+from unittest.mock import patch
+
+import frappe
+from frappe.tests import IntegrationTestCase
+
+from csf_tz.csf_tz.doctype.csf_api_response_log.csf_api_response_log import add_log
+from csf_tz.patches import migrate_vfd_providers_to_csf_tz
+from csf_tz.patches.custom_fields import vfd_providers_updated_custom_fields
+
+VFD_FIELDS = [
+ ("Customer", "vfd_cust_id"),
+ ("Customer", "vfd_cust_id_type"),
+ ("Sales Invoice", "vfd_rctvnum"),
+ ("Sales Invoice", "vfd_status"),
+ ("Sales Invoice", "is_auto_generate_vfd"),
+ ("Item Tax Template", "vfd_taxcode"),
+ ("Mode of Payment", "vfd_pmttype"),
+]
+
+
+def custom_field_count():
+ return frappe.db.count("Custom Field", {"fieldname": ["like", "vfd_%"]})
+
+
+class TestVFDPatches(IntegrationTestCase):
+ def test_migrate_vfd_providers_sets_app_name(self):
+ for module in migrate_vfd_providers_to_csf_tz.MODULES:
+ frappe.db.set_value("Module Def", module, "app_name", "erpnext", update_modified=False)
+ migrate_vfd_providers_to_csf_tz.execute()
+ migrate_vfd_providers_to_csf_tz.execute()
+ for module in migrate_vfd_providers_to_csf_tz.MODULES:
+ self.assertEqual(frappe.db.get_value("Module Def", module, "app_name"), "csf_tz")
+
+ def test_vfd_custom_fields_patch_is_idempotent(self):
+ vfd_providers_updated_custom_fields.execute()
+ count = custom_field_count()
+ vfd_providers_updated_custom_fields.execute()
+ self.assertEqual(custom_field_count(), count)
+ for doctype, fieldname in VFD_FIELDS:
+ self.assertTrue(
+ frappe.db.exists("Custom Field", {"dt": doctype, "fieldname": fieldname}), fieldname
+ )
+ options = frappe.db.get_value(
+ "Custom Field", {"dt": "Sales Invoice", "fieldname": "vfd_status"}, "options"
+ )
+ self.assertEqual(options.split("\n"), ["Not Sent", "Pending", "Failed", "Success"])
+
+
+class TestCSFAPIResponseLog(IntegrationTestCase):
+ def test_add_log_stores_request_and_response(self):
+ with patch.object(frappe.db, "commit"):
+ name = add_log("POST", "https://api.test/x", {"h": 1}, {"b": 2}, {"r": 3}, 201)
+ log = frappe.get_doc("CSF API Response Log", name)
+ self.assertEqual(log.request_type, "POST")
+ self.assertEqual(log.request_url, "https://api.test/x")
+ self.assertEqual(log.request_header, "{'h': 1}")
+ self.assertEqual(log.request_body, "{'b': 2}")
+ self.assertEqual(log.response_data, "{'r': 3}")
+ self.assertEqual(log.status_code, "201")
+ self.assertEqual(log.user_id, "Administrator")
+
+
+class TestCSFTZSettings(IntegrationTestCase):
+ def settings(self):
+ return frappe.get_single("CSF TZ Settings")
+
+ def reset_populate_flag(self):
+ frappe.db.set_single_value("CSF TZ Settings", "populate_tz_regions", 0)
+
+ def realtime_message(self, publish):
+ return next(
+ c.kwargs["message"] for c in publish.call_args_list if c.kwargs.get("event") == "msgprint"
+ )
+
+ def test_working_days_must_be_in_range(self):
+ for days in (31, 0):
+ settings = self.settings()
+ settings.enable_fixed_working_days_per_month = 1
+ settings.working_days_per_month = days
+ self.assertRaisesRegex(frappe.ValidationError, "between 1 and 30", settings.save)
+ settings = self.settings()
+ settings.enable_fixed_working_days_per_month = 1
+ settings.working_days_per_month = 26
+ settings.save()
+
+ def test_email_queue_batch_size_updates_site_config(self):
+ settings = self.settings()
+ settings.override_email_queue_batch_size = 1
+ settings.email_qatch_batch_size = 75
+ with patch("csf_tz.csf_tz.doctype.csf_tz_settings.csf_tz_settings.update_site_config") as update:
+ settings.save()
+ update.assert_called_once_with("email_queue_batch_size", 75)
+
+ def test_populate_tz_regions_enqueues_background_job(self):
+ self.addCleanup(self.reset_populate_flag)
+ settings = self.settings()
+ settings.populate_tz_regions = 1
+ with patch.object(frappe, "enqueue") as enqueue:
+ settings.save()
+ self.assertEqual(enqueue.call_args.kwargs["method"].__name__, "populate_tz_regions_background")
+ self.assertEqual(enqueue.call_args.kwargs["queue"], "long")
+ self.assertEqual(enqueue.call_args.kwargs["job_name"], "populate_tz_regions")
+ with patch.object(frappe, "enqueue") as enqueue:
+ settings.save()
+ enqueue.assert_not_called()
+
+ def test_populate_tz_regions_resets_flag_when_enqueue_fails(self):
+ self.reset_populate_flag()
+ settings = self.settings()
+ settings.populate_tz_regions = 1
+ with patch.object(frappe, "enqueue", side_effect=RuntimeError("no worker")):
+ settings.save()
+ self.assertEqual(frappe.db.get_single_value("CSF TZ Settings", "populate_tz_regions"), 0)
+
+ def test_populate_tz_regions_background(self):
+ settings = self.settings()
+ with (
+ patch("csf_tz.patches.tz_post_code.create_tz_post_code.execute") as execute,
+ patch.object(frappe.db, "commit"),
+ patch.object(frappe, "publish_realtime") as publish,
+ ):
+ settings.populate_tz_regions_background()
+ execute.assert_called_once()
+ self.assertIn("completed", self.realtime_message(publish))
+ self.assertEqual(frappe.db.get_single_value("CSF TZ Settings", "tz_regions_populated"), 1)
+ self.assertEqual(frappe.db.get_single_value("CSF TZ Settings", "populate_tz_regions"), 0)
+
+ def test_populate_tz_regions_background_failure(self):
+ settings = self.settings()
+ with (
+ patch(
+ "csf_tz.patches.tz_post_code.create_tz_post_code.execute", side_effect=RuntimeError("boom")
+ ),
+ patch.object(frappe.db, "commit"),
+ patch.object(frappe, "publish_realtime") as publish,
+ ):
+ settings.populate_tz_regions_background()
+ self.assertIn("failed: boom", self.realtime_message(publish))
+ self.assertEqual(frappe.db.get_single_value("CSF TZ Settings", "tz_regions_populated"), 0)
diff --git a/csf_tz/tests/test_vfd_tra_tax_inv.py b/csf_tz/tests/test_vfd_tra_tax_inv.py
new file mode 100644
index 00000000..e325e02e
--- /dev/null
+++ b/csf_tz/tests/test_vfd_tra_tax_inv.py
@@ -0,0 +1,382 @@
+from unittest.mock import MagicMock, patch
+
+import frappe
+import requests
+from bs4 import BeautifulSoup
+from frappe.tests import IntegrationTestCase
+
+from csf_tz.csf_tz.doctype.tra_tax_inv import tra_tax_inv as tra
+from csf_tz.tests.vfd_test_records import COMPANY, ITEM
+
+SESSION = "csf_tz.csf_tz.doctype.tra_tax_inv.tra_tax_inv.requests.Session"
+CODE = "ABC123_102030"
+FORM_HTML = ''
+RECEIPT_HTML = f"""TRA Receipt
+ACME LTD
+
+TIN: 123456789
+VRN: 40-123456-A
+SERIAL NO: 10TZ100001
+UIN: UIN123
+TAX OFFICE: Ilala
+MOBILE: 0712345678
+P.O.BOX 123 DAR
+
+
+
+| Description | Qty | Amount |
+| {ITEM} | 2 | 1,000.00 |
| Unknown Widget | 1 | 500.00 |
+
+| TOTAL EXCL OF TAX: | 2,118.64 |
+| TOTAL TAX: | 381.36 |
+| TOTAL INCL OF TAX: | 2,500.00 |
+| TAX RATE A (18%) | 381.36 |
+
+RECEIPT VERIFICATION CODE
{CODE}
+
+"""
+
+
+def html_response(text, url="https://verify.tra.go.tz/Verify/Verified"):
+ response = MagicMock()
+ response.text = text
+ response.status_code = 200
+ response.url = url
+ response.headers = {}
+ response.cookies = {}
+ return response
+
+
+def mock_session(form_html=FORM_HTML, ask_for_time=True):
+ session = MagicMock()
+ session.headers = {}
+ if ask_for_time:
+ session.post.return_value = html_response("Please provide your Receipt time")
+ session.get.side_effect = [html_response(form_html), html_response(RECEIPT_HTML)]
+ else:
+ session.post.return_value = html_response(RECEIPT_HTML)
+ session.get.side_effect = [html_response(form_html)]
+ return session
+
+
+def make_tra_doc(code, items=None, **values):
+ doc = frappe.get_doc(
+ {
+ "doctype": "TRA TAX Inv",
+ "type": "Sales",
+ "verification_code": code,
+ "company_name": "ACME LTD",
+ "customer_name": "John Doe",
+ "grand_total": 200,
+ "items": items if items is not None else [{"description": ITEM, "quantity": "2", "amount": 100}],
+ **values,
+ }
+ )
+ return doc.insert(ignore_links=True)
+
+
+class TestVerifyTraReceipt(IntegrationTestCase):
+ def verify(self, session=None, **kwargs):
+ with patch(SESSION, return_value=session or mock_session()):
+ return tra.verify_tra_receipt(**kwargs)
+
+ def test_creates_verified_document_from_receipt(self):
+ session = mock_session()
+ result = self.verify(session, verification_code=CODE)
+ self.assertTrue(result["success"], result)
+ self.assertEqual(result["company_name"], "ACME LTD")
+ self.assertEqual(result["receipt_number"], "RCT-001")
+ self.assertEqual(result["total"], 2500)
+
+ self.assertEqual(
+ session.post.call_args.kwargs["data"], {"__RequestVerificationToken": "tok123", "RctVcode": CODE}
+ )
+ self.assertIn("Secret=10:20:30", session.get.call_args_list[1].args[0])
+
+ doc = frappe.get_doc("TRA TAX Inv", result["doc_name"])
+ self.assertEqual(doc.verification_status, "Verified")
+ self.assertEqual(doc.type, "Sales")
+ self.assertEqual(doc.customer_name, "John Doe")
+ self.assertEqual(doc.customer_id_type, "TIN")
+ self.assertEqual(doc.customer_id, "987654321")
+ self.assertEqual(doc.customer_mobile, "0755000000")
+ self.assertEqual(doc.subtotal, 2118.64)
+ self.assertEqual(doc.total_tax, 381.36)
+ self.assertEqual(doc.grand_total, 2500)
+ self.assertEqual([row.description for row in doc.items], [ITEM, "Unknown Widget"])
+ self.assertEqual(doc.items[0].quantity, "2")
+ self.assertEqual(doc.items[0].amount, 1000)
+
+ def test_duplicate_code_is_rejected(self):
+ self.verify(verification_code="DUP123_102030")
+ result = self.verify(verification_code="DUP123_102030")
+ self.assertFalse(result["success"])
+ self.assertIn("already exists", result["message"])
+
+ def test_accepts_qr_code_url(self):
+ result = self.verify(qr_code_data="https://verify.tra.go.tz/QRC123_102030")
+ self.assertTrue(result["success"])
+ self.assertEqual(result["verification_code"], "QRC123_102030")
+ result = self.verify(verification_code="https://verify.tra.go.tz/URL123_102030")
+ self.assertEqual(result["verification_code"], "URL123_102030")
+
+ def test_rejects_invalid_input(self):
+ result = tra.verify_tra_receipt(qr_code_data="nonsense")
+ self.assertFalse(result["success"])
+ self.assertEqual(result["message"], "Invalid QR code data format")
+ result = tra.verify_tra_receipt()
+ self.assertEqual(result["message"], "No verification code provided")
+
+ def test_creates_failed_document_when_tra_is_unreachable(self):
+ session = MagicMock()
+ session.headers = {}
+ session.get.side_effect = requests.exceptions.ConnectionError("offline")
+ result = self.verify(session, verification_code="OFF123_102030")
+ self.assertTrue(result["success"])
+ doc = frappe.get_doc("TRA TAX Inv", result["doc_name"])
+ self.assertEqual(doc.verification_status, "Failed")
+ self.assertFalse(doc.items)
+
+ def test_guest_cannot_create_documents(self):
+ frappe.set_user("Guest")
+ self.addCleanup(frappe.set_user, "Administrator")
+ result = self.verify(verification_code="GST123_102030")
+ self.assertFalse(result["success"])
+ self.assertIn("Failed to create document", result["message"])
+
+
+class TestFetchTraVerification(IntegrationTestCase):
+ def test_validates_verification_code_format(self):
+ self.assertIn("does not contain time", tra.fetch_tra_verification("ABC123")["error"])
+ self.assertIn("Invalid time format", tra.fetch_tra_verification("ABC123_12")["error"])
+
+ def test_requires_form_token(self):
+ with patch(SESSION, return_value=mock_session(form_html="")):
+ result = tra.fetch_tra_verification(CODE)
+ self.assertEqual(result["error"], "Could not find verification token in form")
+
+ def test_parses_receipt_without_time_prompt(self):
+ session = mock_session(ask_for_time=False)
+ with patch(SESSION, return_value=session):
+ result = tra.fetch_tra_verification(CODE)
+ self.assertEqual(session.get.call_count, 1)
+ self.assertEqual(result["receipt_time_used"], "10:20:30")
+ self.assertEqual(result["form_token_used"], "tok123")
+ self.assertEqual(result["title"], "TRA Receipt")
+ self.assertEqual(result["verification_data"]["html_content"], RECEIPT_HTML)
+ self.assertEqual(len(result["verification_data"]["tables"]), 2)
+
+ def test_request_errors_are_reported(self):
+ session = MagicMock()
+ session.headers = {}
+ session.get.side_effect = requests.exceptions.Timeout("slow")
+ with patch(SESSION, return_value=session):
+ result = tra.fetch_tra_verification(CODE)
+ self.assertIn("Request failed", result["error"])
+
+
+class TestReceiptParsing(IntegrationTestCase):
+ def test_extract_receipt_from_html(self):
+ data = tra.extract_receipt_from_html(RECEIPT_HTML)
+ self.assertEqual(data["company_info"]["name"], "ACME LTD")
+ self.assertEqual(data["company_info"]["tin"], "123456789")
+ self.assertEqual(data["company_info"]["vrn"], "40-123456-A")
+ self.assertEqual(data["company_info"]["serial_number"], "10TZ100001")
+ self.assertEqual(data["company_info"]["uin"], "UIN123")
+ self.assertEqual(data["company_info"]["tax_office"], "Ilala")
+ self.assertEqual(data["company_info"]["mobile"], "0712345678")
+ self.assertEqual(data["company_info"]["address"], "P.O.BOX 123 DAR")
+ self.assertEqual(
+ data["receipt_info"],
+ {"receipt_number": "RCT-001", "z_number": "Z-77", "date": "25/08/2026", "time": "10:20:30"},
+ )
+ self.assertEqual(
+ data["totals"], {"subtotal": "2,118.64", "total_tax": "381.36", "grand_total": "2,500.00"}
+ )
+ self.assertEqual(data["taxes"], [{"label": "TAX RATE A (18%)", "amount": "381.36", "rate": "18%"}])
+ self.assertEqual(data["verification_info"]["code"], CODE)
+ self.assertEqual(data["verification_info"]["verification_url"], f"https://verify.tra.go.tz/{CODE}")
+ self.assertEqual(data["verification_info"]["qr_code_data"], f"https://verify.tra.go.tz/{CODE}")
+
+ def test_extract_verification_data(self):
+ data = tra.extract_verification_data(BeautifulSoup(RECEIPT_HTML, "html.parser"))
+ self.assertEqual(data["tables"][0][1], [ITEM, "2", "1,000.00"])
+ self.assertIn("ACME LTD", data["all_text"])
+ self.assertNotIn("form_inputs", data)
+ form = tra.extract_verification_data(BeautifulSoup(FORM_HTML, "html.parser"))
+ self.assertEqual(form["form_inputs"][0]["name"], "__RequestVerificationToken")
+
+ def test_extract_receipt_data_prefers_html(self):
+ data = tra.extract_receipt_data({"html_content": RECEIPT_HTML})
+ self.assertEqual(data["receipt_info"]["receipt_number"], "RCT-001")
+ self.assertEqual(tra.extract_receipt_data({})["items"], [])
+ self.assertEqual(tra.extract_receipt_data({"status_elements": []})["totals"], {})
+
+ def test_extract_receipt_data_from_tables(self):
+ tables = [
+ [["Description", "Qty", "Amount"], ["Widget", "1", "10"], ["", "", ""]],
+ [
+ ["Total Excl", "100"],
+ ["Total Incl", "118"],
+ ["Tax Total", "18"],
+ ["VAT", "18"],
+ ["Receipt No", "R1"],
+ ["Date", "2026-08-25"],
+ ["Time", "10:00"],
+ ["TIN", "111"],
+ ["VRN", "222"],
+ ],
+ ]
+ data = tra.extract_receipt_data({"tables": tables})
+ self.assertEqual(data["items"], [{"description": "Widget", "quantity": "1", "amount": "10"}])
+ self.assertEqual(data["totals"], {"subtotal": "100", "grand_total": "118", "total_tax": "18"})
+ self.assertEqual(data["taxes"], [{"type": "VAT", "amount": "18"}])
+ self.assertEqual(
+ data["receipt_info"], {"receipt_number": "R1", "date": "2026-08-25", "time": "10:00"}
+ )
+ self.assertEqual(data["company_info"], {"tin": "111", "vrn": "222"})
+
+ def test_create_tra_tax_inv_document(self):
+ receipt = tra.extract_receipt_from_html(RECEIPT_HTML)
+ result = tra.create_tra_tax_inv_document("NEW123_102030", receipt, {"url": "https://x"})
+ self.assertTrue(result["success"])
+ doc = frappe.get_doc("TRA TAX Inv", result["doc_name"])
+ self.assertEqual(doc.type, "Purchase")
+ self.assertEqual(doc.grand_total, 2500)
+ self.assertEqual(len(doc.items), 2)
+ duplicate = tra.create_tra_tax_inv_document("NEW123_102030", receipt, {})
+ self.assertFalse(duplicate["success"])
+ self.assertEqual(duplicate["existing_doc"], doc.name)
+
+ def test_create_tra_tax_inv_document_safe_handles_bad_amounts(self):
+ receipt = {
+ "totals": {"subtotal": "n/a", "grand_total": "1,0"},
+ "items": [{"description": "X", "amount": "?"}],
+ }
+ result = tra.create_tra_tax_inv_document_safe("BAD123_102030", receipt, {"success": False})
+ self.assertTrue(result["success"])
+ doc = frappe.get_doc("TRA TAX Inv", result["doc_name"])
+ self.assertEqual(doc.verification_status, "Failed")
+ self.assertEqual(doc.subtotal, 0)
+ self.assertEqual(doc.grand_total, 10)
+ self.assertEqual(doc.items[0].amount, 0)
+ self.assertEqual(result["items_count"], 1)
+
+
+class TestCreateInvoiceFromTraTaxInv(IntegrationTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ frappe.defaults.set_user_default("company", COMPANY)
+ frappe.clear_cache(user="Administrator")
+ assert frappe.defaults.get_user_default("Company") == COMPANY
+
+ def test_creates_sales_invoice_and_customer(self):
+ doc = make_tra_doc("SI0001_102030")
+ result = tra.create_invoice_from_tra_tax_inv(doc.name, "Sales Invoice")
+ self.assertTrue(result["success"], result)
+ invoice = frappe.get_doc("Sales Invoice", result["invoice_name"])
+ self.assertEqual(invoice.customer_name, "ACME LTD")
+ self.assertEqual(invoice.items[0].item_code, ITEM)
+ self.assertEqual(invoice.items[0].qty, 2)
+ self.assertEqual(invoice.items[0].rate, 100)
+ self.assertEqual(invoice.docstatus, 0)
+ doc.reload()
+ self.assertEqual(doc.reference_doctype, "Sales Invoice")
+ self.assertEqual(doc.reference_docname, invoice.name)
+ again = tra.create_invoice_from_tra_tax_inv(doc.name, "Sales Invoice")
+ self.assertFalse(again["success"])
+ self.assertIn("Invoice already created", again["message"])
+
+ def test_creates_purchase_invoice_and_supplier(self):
+ doc = make_tra_doc("PI0001_102030", type="Purchase", receipt_number="RCT-9")
+ result = doc.create_purchase_invoice()
+ self.assertTrue(result["success"], result)
+ invoice = frappe.get_doc("Purchase Invoice", result["invoice_name"])
+ self.assertEqual(invoice.supplier_name, "John Doe")
+ self.assertEqual(invoice.bill_no, "RCT-9")
+ self.assertEqual(invoice.items[0].item_code, ITEM)
+ self.assertTrue(frappe.db.exists("Supplier", {"supplier_name": "John Doe"}))
+
+ def test_document_method_creates_sales_invoice(self):
+ doc = make_tra_doc("SI0002_102030")
+ self.assertTrue(doc.create_sales_invoice()["success"])
+
+ def test_validation_reports_missing_masters(self):
+ empty = make_tra_doc("EMP001_102030", items=[])
+ result = tra.create_invoice_from_tra_tax_inv(empty.name, "Sales Invoice")
+ self.assertIn("No items found", result["message"])
+
+ missing = make_tra_doc(
+ "MIS001_102030",
+ items=[
+ {"description": "Unknown Widget"},
+ {"description": "Mapped", "mapped_item_code": "_Missing Item"},
+ ],
+ )
+ result = tra.create_invoice_from_tra_tax_inv(missing.name, "Sales Invoice")
+ self.assertFalse(result["success"])
+ self.assertEqual(result["missing_items"], ["Unknown Widget", "Mapped (mapped to: _Missing Item)"])
+
+ no_company = make_tra_doc("NOC001_102030", company_name="")
+ result = tra.create_invoice_from_tra_tax_inv(no_company.name, "Sales Invoice")
+ self.assertIn("Customer: No company name", result["missing_party"])
+ no_customer = make_tra_doc("NOS001_102030", customer_name="")
+ result = tra.create_invoice_from_tra_tax_inv(no_customer.name, "Purchase Invoice")
+ self.assertIn("Supplier: No customer name", result["missing_party"])
+
+ def test_invalid_invoice_type(self):
+ doc = make_tra_doc("TYP001_102030")
+ result = tra.create_invoice_from_tra_tax_inv(doc.name, "Journal Entry")
+ self.assertIn("Invalid invoice type", result["message"])
+
+ def test_user_without_permission_cannot_create_invoice(self):
+ doc = make_tra_doc("PRM001_102030")
+ user = "vfd-noperm@example.com"
+ if not frappe.db.exists("User", user):
+ frappe.get_doc(
+ {"doctype": "User", "email": user, "first_name": "No Perm", "send_welcome_email": 0}
+ ).insert()
+ frappe.set_user(user)
+ self.addCleanup(frappe.set_user, "Administrator")
+ result = tra.create_invoice_from_tra_tax_inv(doc.name, "Sales Invoice")
+ self.assertFalse(result["success"])
+
+ def test_get_or_suggest_item(self):
+ self.assertEqual(tra.get_or_suggest_item(frappe._dict(mapped_item_code=ITEM, description="x")), ITEM)
+ self.assertEqual(
+ tra.get_or_suggest_item(frappe._dict(mapped_item_code="_Nope", description=ITEM)), ITEM
+ )
+ self.assertEqual(tra.get_or_suggest_item(frappe._dict(description="_Test Item")), "_Test Item")
+ self.assertEqual(tra.get_or_suggest_item(frappe._dict(description="Unknown")), "Unknown")
+ self.assertIsNone(tra.get_or_suggest_item(frappe._dict(description="")))
+
+ def test_get_or_create_party(self):
+ self.assertIsNone(tra.get_or_create_customer(""))
+ self.assertIsNone(tra.get_or_create_supplier(""))
+ self.assertEqual(tra.get_or_create_customer("_Test Customer"), "_Test Customer")
+ self.assertEqual(tra.get_or_create_supplier("_Test Supplier"), "_Test Supplier")
+ created = tra.get_or_create_customer("Brand New Customer")
+ customer_group = frappe.db.get_value("Customer", created, "customer_group")
+ self.assertEqual(frappe.db.get_value("Customer Group", customer_group, "is_group"), 0)
+ self.assertEqual(tra.get_or_create_customer("Brand New Customer"), created)
+ supplier = tra.get_or_create_supplier("Brand New Supplier")
+ self.assertEqual(frappe.db.get_value("Supplier", supplier, "supplier_type"), "Company")
+
+ def test_get_or_suggest_party(self):
+ self.assertIsNone(tra.get_or_suggest_customer(""))
+ self.assertIsNone(tra.get_or_suggest_supplier(""))
+ self.assertEqual(tra.get_or_suggest_customer("_Test Customer"), "_Test Customer")
+ self.assertEqual(tra.get_or_suggest_customer("Ghost"), "Ghost")
+ self.assertEqual(tra.get_or_suggest_supplier("_Test Supplier"), "_Test Supplier")
+ self.assertEqual(tra.get_or_suggest_supplier("Ghost"), "Ghost")
diff --git a/csf_tz/tests/vfd_test_records.py b/csf_tz/tests/vfd_test_records.py
new file mode 100644
index 00000000..d04b77a1
--- /dev/null
+++ b/csf_tz/tests/vfd_test_records.py
@@ -0,0 +1,245 @@
+"""Shared VFD test records: providers, settings, tax templates, customers and invoices."""
+
+import json
+from unittest.mock import MagicMock, patch
+
+import frappe
+from frappe.utils import add_days, nowdate
+
+COMPANY = "_Test Company"
+CUSTOMER = "_Test VFD Customer"
+ITEM = "_Test Non Stock Item"
+SECOND_ITEM = "_Test VFD Service Item"
+VAT_ACCOUNT = "_Test Account VAT - _TC"
+STANDARD_TEMPLATE = "VFD Standard Rate - _TC"
+EXEMPT_TEMPLATE = "VFD Exempt - _TC"
+STANDARD_ZERO_TEMPLATE = "VFD Standard Zero - _TC"
+STANDARD_OTHER_TEMPLATE = "VFD Standard Other - _TC"
+NO_CODE_TEMPLATE = "VFD No Code - _TC"
+
+PROVIDERS = {
+ "VFDPlus": {
+ "settings": "VFDPlus Settings",
+ "base_url": "https://vfdplus.test/api/",
+ "attributes": {"post_fiscal_receipt": "receipt", "serial_info": "serial", "account_info": "account"},
+ },
+ "TotalVFD": {
+ "settings": "Total VFD Setting",
+ "base_url": "https://totalvfd.test/",
+ "attributes": {"sales": "sales"},
+ },
+ "SimplifyVFD": {
+ "settings": "Simplify VFD Settings",
+ "base_url": "https://simplify.test/",
+ "attributes": {"login": "login", "refresh": "refresh", "createIssuedInvoice": "invoice"},
+ },
+}
+
+SERIAL_INFO = {
+ "msg_status": "OK",
+ "msg_code": 2000,
+ "msg_data": {"serial_id": "SER-1", "serial_code": "CRED-1", "tin": "123456789", "vat_enabled": 1},
+}
+
+
+def fake_response(body, status_code=200, ok=True):
+ response = MagicMock()
+ response.ok = ok
+ response.status_code = status_code
+ response.text = json.dumps(body)
+ response.json.return_value = body
+ response.url = "https://mocked.test/"
+ response.headers = {}
+ response.cookies = {}
+ return response
+
+
+def make_item_tax_template(name, vfd_taxcode, rate):
+ if frappe.db.exists("Item Tax Template", name):
+ return name
+ doc = frappe.get_doc(
+ {
+ "doctype": "Item Tax Template",
+ "title": name.replace(" - _TC", ""),
+ "company": COMPANY,
+ "vfd_taxcode": vfd_taxcode,
+ "taxes": [{"tax_type": VAT_ACCOUNT, "tax_rate": rate}],
+ }
+ )
+ doc.insert(ignore_permissions=True)
+ return doc.name
+
+
+def make_item_tax_templates():
+ make_item_tax_template(STANDARD_TEMPLATE, "1- Standard Rate (18%)", 18)
+ make_item_tax_template(EXEMPT_TEMPLATE, "5- Exempt", 0)
+ make_item_tax_template(STANDARD_ZERO_TEMPLATE, "1- Standard Rate (18%)", 0)
+ make_item_tax_template(STANDARD_OTHER_TEMPLATE, "1- Standard Rate (18%)", 10)
+ make_item_tax_template(NO_CODE_TEMPLATE, "", 18)
+
+
+def make_second_item():
+ if frappe.db.exists("Item", SECOND_ITEM):
+ return SECOND_ITEM
+ frappe.get_doc(
+ {
+ "doctype": "Item",
+ "item_code": SECOND_ITEM,
+ "item_name": SECOND_ITEM,
+ "item_group": "_Test Item Group",
+ "stock_uom": "Nos",
+ "is_stock_item": 0,
+ }
+ ).insert(ignore_permissions=True)
+ return SECOND_ITEM
+
+
+def make_customer(name=CUSTOMER, tax_id="123-456-789"):
+ if frappe.db.exists("Customer", name):
+ return frappe.get_doc("Customer", name)
+ doc = frappe.get_doc(
+ {
+ "doctype": "Customer",
+ "customer_name": name,
+ "customer_group": "_Test Customer Group",
+ "territory": "_Test Territory",
+ "tax_id": tax_id,
+ "mobile_no": "+255 (0)712-345-678",
+ }
+ )
+ doc.insert(ignore_permissions=True)
+ return doc
+
+
+def make_vfd_providers():
+ for name, info in PROVIDERS.items():
+ if frappe.db.exists("VFD Provider", name):
+ continue
+ frappe.get_doc(
+ {
+ "doctype": "VFD Provider",
+ "vfd_provider": name,
+ "vfd_provider_settings": info["settings"],
+ "base_url": info["base_url"],
+ "attributes": [{"key": key, "value": value} for key, value in info["attributes"].items()],
+ }
+ ).insert(ignore_permissions=True)
+
+
+def set_company_provider(provider, company=COMPANY):
+ if frappe.db.exists("Company VFD Provider", company):
+ doc = frappe.get_doc("Company VFD Provider", company)
+ doc.vfd_provider = provider
+ doc.save(ignore_permissions=True)
+ else:
+ doc = frappe.get_doc(
+ {"doctype": "Company VFD Provider", "company": company, "vfd_provider": provider}
+ ).insert(ignore_permissions=True)
+ frappe.clear_document_cache("Company VFD Provider", company)
+ return doc
+
+
+def make_vfdplus_settings(company=COMPANY):
+ if frappe.db.exists("VFDPlus Settings", company):
+ return frappe.get_doc("VFDPlus Settings", company)
+ doc = frappe.get_doc(
+ {
+ "doctype": "VFDPlus Settings",
+ "company": company,
+ "vfdplus_api_key": "plus-key",
+ "vfd_start_date": add_days(nowdate(), -30),
+ }
+ )
+ with patch(
+ "csf_tz.vfd_providers.doctype.vfdplus_settings.vfdplus_settings.requests.request",
+ return_value=fake_response(SERIAL_INFO),
+ ):
+ doc.insert(ignore_permissions=True)
+ return doc
+
+
+def make_total_vfd_setting(company=COMPANY):
+ if frappe.db.exists("Total VFD Setting", company):
+ return frappe.get_doc("Total VFD Setting", company)
+ return frappe.get_doc(
+ {
+ "doctype": "Total VFD Setting",
+ "company": company,
+ "serial_id": "TOTAL-SERIAL",
+ "bearer_token": "total-token",
+ "x_active_business": "business-1",
+ "vfd_start_date": add_days(nowdate(), -30),
+ }
+ ).insert(ignore_permissions=True)
+
+
+def make_simplify_settings(company=COMPANY):
+ if frappe.db.exists("Simplify VFD Settings", company):
+ return frappe.get_doc("Simplify VFD Settings", company)
+ return frappe.get_doc(
+ {
+ "doctype": "Simplify VFD Settings",
+ "company": company,
+ "username": "simplify-user",
+ "password": "simplify-pass",
+ "bearer_token": "simplify-token",
+ "refresh_token": "simplify-refresh",
+ "vfd_start_date": add_days(nowdate(), -30),
+ }
+ ).insert(ignore_permissions=True)
+
+
+def make_sales_invoice(
+ item_tax_template=STANDARD_TEMPLATE, tax_rate=18, with_taxes=True, submit=False, **values
+):
+ items = values.pop("items", None) or [{"item_code": ITEM, "qty": 2, "rate": 100}]
+ doc = frappe.get_doc(
+ {
+ "doctype": "Sales Invoice",
+ "company": COMPANY,
+ "customer": values.pop("customer", CUSTOMER),
+ "debit_to": "Debtors - _TC",
+ "currency": "INR",
+ "conversion_rate": 1,
+ "set_posting_time": 1,
+ "posting_date": nowdate(),
+ "due_date": nowdate(),
+ **values,
+ }
+ )
+ for item in items:
+ doc.append(
+ "items",
+ {
+ "item_tax_template": item_tax_template,
+ "income_account": "Sales - _TC",
+ "cost_center": "_Test Cost Center - _TC",
+ **item,
+ },
+ )
+ if with_taxes:
+ doc.append(
+ "taxes",
+ {
+ "charge_type": "On Net Total",
+ "account_head": VAT_ACCOUNT,
+ "rate": tax_rate,
+ "description": "VAT",
+ "cost_center": "_Test Cost Center - _TC",
+ },
+ )
+ doc.insert()
+ if submit:
+ doc.submit()
+ return doc
+
+
+def make_vfd_records():
+ """Create every VFD test record on the test company."""
+ make_item_tax_templates()
+ make_second_item()
+ make_customer()
+ make_vfd_providers()
+ make_total_vfd_setting()
+ make_simplify_settings()
+ make_vfdplus_settings()
diff --git a/csf_tz/tests/whitelisted_methods.json b/csf_tz/tests/whitelisted_methods.json
new file mode 100644
index 00000000..1218cb70
--- /dev/null
+++ b/csf_tz/tests/whitelisted_methods.json
@@ -0,0 +1,803 @@
+[
+ {
+ "path": "csf_tz.api.selcom.create_order_minimal",
+ "args": [],
+ "decorator": "frappe.whitelist()",
+ "line": 22,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.bank_api.receive_callback",
+ "args": [],
+ "decorator": "frappe.whitelist(allow_guest=True)",
+ "line": 21,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.bank_api.receive_validate_reference",
+ "args": [],
+ "decorator": "frappe.whitelist(allow_guest=True)",
+ "line": 27,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.budget_check.check_budget_before_submit",
+ "args": [
+ "doctype",
+ "docname",
+ "setting_field"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 86,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csf_tz.dashboard_chart_source.multi_account_balance_timeline.multi_account_balance_timeline.get",
+ "args": [
+ "chart_name",
+ "chart",
+ "no_cache",
+ "filters",
+ "from_date",
+ "to_date",
+ "timespan",
+ "time_interval",
+ "heatmap_year"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 13,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csf_tz.dashboard_chart_source.multi_account_balance_timeline.multi_account_balance_timeline.get_sample_data",
+ "args": [
+ "chart_name"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 33,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csf_tz.dashboard_chart_source.multi_account_balance_timeline.multi_account_balance_timeline.create_sample_accounts",
+ "args": [
+ "company"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 68,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csf_tz.dashboard_chart_source.multi_account_balance_timeline.multi_account_balance_timeline.debug_chart_data",
+ "args": [
+ "company"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 83,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csf_tz.dashboard_chart_source.multi_account_balance_timeline.multi_account_balance_timeline.create_test_transactions",
+ "args": [
+ "company",
+ "account_name",
+ "amount"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 872,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csf_tz.dashboard_chart_source.multi_account_balance_timeline.multi_account_balance_timeline.create_sample_accounts",
+ "args": [
+ "self",
+ "company"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 612,
+ "in_class": "MultiBankBalance"
+ },
+ {
+ "path": "csf_tz.csf_tz.doctype.efd_z_report.efd_z_report.get_sales_invoice",
+ "args": [
+ "self"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 25,
+ "in_class": "EFDZReport"
+ },
+ {
+ "path": "csf_tz.csf_tz.doctype.foreign_import_transaction.foreign_import_transaction.recalculate_differences",
+ "args": [
+ "self"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 146,
+ "in_class": "ForeignImportTransaction"
+ },
+ {
+ "path": "csf_tz.csf_tz.doctype.foreign_import_transaction.foreign_import_transaction.get_exchange_summary",
+ "args": [
+ "self"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 156,
+ "in_class": "ForeignImportTransaction"
+ },
+ {
+ "path": "csf_tz.csf_tz.doctype.latra_licenses.latra_licenses.update_latra_records",
+ "args": [
+ "force"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 82,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csf_tz.doctype.latra_licenses.latra_licenses.update_latra_offences",
+ "args": [
+ "force"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 116,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csf_tz.doctype.tra_tax_inv.tra_tax_inv.verify_tra_receipt",
+ "args": [
+ "verification_code",
+ "qr_code_data"
+ ],
+ "decorator": "frappe.whitelist(allow_guest=True)",
+ "line": 26,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csf_tz.doctype.tra_tax_inv.tra_tax_inv.create_invoice_from_tra_tax_inv",
+ "args": [
+ "tra_tax_inv_name",
+ "invoice_type"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 822,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csf_tz.doctype.tz_insurance_cover_note.tz_insurance_cover_note.update_covernote_docs",
+ "args": [],
+ "decorator": "frappe.whitelist()",
+ "line": 25,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csf_tz.doctype.vehicle_fine_record.vehicle_fine_record.get_fine",
+ "args": [
+ "number_plate"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 252,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csf_tz.doctype.vehicle_sync_task.processor.run_vehicle_batch",
+ "args": [],
+ "decorator": "frappe.whitelist()",
+ "line": 49,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csf_tz.doctype.vehicle_sync_task.processor.create_sync_task",
+ "args": [
+ "vehicle_no",
+ "priority",
+ "immediate"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 89,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csf_tz.doctype.vehicle_sync_task.processor.seed_vehicle_sync_queue",
+ "args": [],
+ "decorator": "frappe.whitelist()",
+ "line": 161,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csf_tz.page.jobcards.jobcards.get_job_cards",
+ "args": [],
+ "decorator": "frappe.whitelist()",
+ "line": 7,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csf_tz.page.jobcards.jobcards.get_employees",
+ "args": [
+ "company"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 26,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csf_tz.page.jobcards.jobcards.save_doc",
+ "args": [
+ "doc",
+ "action"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 39,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csf_tz.page.scan_qrcode.scan_qrcode.add_biometric_log",
+ "args": [
+ "data"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 5,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csf_tz.report.salary_register_csf.salary_register_csf.approve",
+ "args": [
+ "data"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 461,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csf_tz.report.salary_register_ctc.salary_register_ctc.approve",
+ "args": [
+ "data"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 277,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csftz_hooks.additional_salary.create_additional_salary_journal",
+ "args": [
+ "doc",
+ "method"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 7,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csftz_hooks.additional_salary.generate_additional_salary_records",
+ "args": [],
+ "decorator": "frappe.whitelist()",
+ "line": 76,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csftz_hooks.additional_salary.get_employee_base_salary_in_hours",
+ "args": [
+ "employee",
+ "payroll_date"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 151,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csftz_hooks.customer.get_customer_total_unpaid_amount",
+ "args": [
+ "customer",
+ "company"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 6,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csftz_hooks.employee_contact_qr.generate_contact_qr",
+ "args": [
+ "employee"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 10,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csftz_hooks.exchange_calculations.create_manual_exchange_entry",
+ "args": [
+ "tracker_name",
+ "reference_type",
+ "reference_name",
+ "difference_type",
+ "amount",
+ "remarks"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 477,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csftz_hooks.exchange_calculations.debug_payment_linking_issue",
+ "args": [
+ "payment_entry_name"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 517,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csftz_hooks.exchange_calculations.manually_link_payment_to_tracker",
+ "args": [
+ "payment_entry_name",
+ "tracker_name"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 598,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csftz_hooks.item_reposting.enqueue_reposting_sle_gle",
+ "args": [],
+ "decorator": "frappe.whitelist()",
+ "line": 102,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csftz_hooks.items_revaluation.get_data",
+ "args": [
+ "filters"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 6,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csftz_hooks.landed_cost_voucher.get_landed_cost_expenses",
+ "args": [
+ "import_file"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 5,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csftz_hooks.payment_entry.get_outstanding_reference_documents",
+ "args": [
+ "args"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 19,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csftz_hooks.payment_entry.get_outstanding_sales_orders",
+ "args": [
+ "args"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 148,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csftz_hooks.payroll.update_slips",
+ "args": [
+ "payroll_entry"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 64,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csftz_hooks.payroll.update_slip",
+ "args": [
+ "salary_slip",
+ "show_message"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 103,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csftz_hooks.payroll.print_slips",
+ "args": [
+ "payroll_entry"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 121,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csftz_hooks.payroll.create_journal_entry",
+ "args": [
+ "payroll_entry"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 213,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.csftz_hooks.payroll.get_amounts_summary",
+ "args": [
+ "payroll_entry"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 292,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.generate_qrcode",
+ "args": [
+ "qrcode_data"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 24,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.app_error_log",
+ "args": [
+ "title",
+ "error"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 33,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.print_out",
+ "args": [
+ "message",
+ "alert",
+ "add_traceback",
+ "to_error_log"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 38,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.get_item_info",
+ "args": [
+ "item_code"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 128,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.get_item_prices",
+ "args": [
+ "item_code",
+ "currency",
+ "customer",
+ "company"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 164,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.get_item_prices_custom",
+ "args": [
+ "filters",
+ "start",
+ "limit"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 209,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.create_delivery_note",
+ "args": [
+ "doc",
+ "method",
+ "doc_name"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 274,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.make_delivery_note",
+ "args": [
+ "source_name",
+ "target_doc",
+ "set_warehouse"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 330,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.get_pending_sales_invoice",
+ "args": [],
+ "decorator": "frappe.whitelist()",
+ "line": 565,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.make_stock_reconciliation_for_all_pending_material_request",
+ "args": [],
+ "decorator": "frappe.whitelist()",
+ "line": 736,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.get_warehouse_options",
+ "args": [
+ "company"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 807,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.make_withholding_tax_gl_entries_for_purchase",
+ "args": [
+ "doc",
+ "method"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 876,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.get_tax_category",
+ "args": [
+ "doc_type",
+ "company"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 984,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.make_withholding_tax_gl_entries_for_sales",
+ "args": [
+ "doc",
+ "method"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 1015,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.account_exists",
+ "args": [
+ "account_name"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 1595,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.auto_create_account",
+ "args": [
+ "abbr"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 1600,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.create_item_tax_template",
+ "args": [
+ "abbr"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 1684,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.create_tax_category",
+ "args": [],
+ "decorator": "frappe.whitelist()",
+ "line": 1724,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.linking_tax_template",
+ "args": [
+ "doctype",
+ "default_tax_template",
+ "abbr"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 1746,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.make_salary_components_and_structure",
+ "args": [
+ "abbr"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 1787,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.get_item_prices_custom_po",
+ "args": [
+ "filters",
+ "start",
+ "limit"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 2030,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.get_item_prices_po",
+ "args": [
+ "item_code",
+ "currency",
+ "customer",
+ "company"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 2095,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.create_write_off_jv_si",
+ "args": [
+ "sales_invoice",
+ "account"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 2141,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.create_write_off_jv_pi",
+ "args": [
+ "purchase_invoice",
+ "account"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 2196,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.custom_api.create_write_off_jv_pe",
+ "args": [
+ "payment_entry",
+ "account"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 2251,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.kcb.api.kcb_api.is_kcb_enabled",
+ "args": [],
+ "decorator": "frappe.whitelist()",
+ "line": 12,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.kcb.api.kcb_api.check_file_status",
+ "args": [
+ "docname"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 176,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.kcb.payments.make_kcb_payments_initiation_from_payment_entries",
+ "args": [
+ "payment_entries"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 172,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.kcb.payments.make_kcb_payments_initiation_from_payroll_entry",
+ "args": [
+ "payroll_entry_name"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 278,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.stanbic.payments.make_payments_initiation",
+ "args": [
+ "payroll_entry_name",
+ "currency",
+ "stanbic_setting_name"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 6,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.utils.create_custom_fields.export_custom_fields",
+ "args": [
+ "docnames"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 66,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.vfd_providers.doctype.simplify_vfd_settings.simplify_vfd_settings.get_payload",
+ "args": [
+ "doc"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 122,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.vfd_providers.doctype.simplify_vfd_settings.simplify_vfd_settings.post_fiscal_receipt",
+ "args": [
+ "doc",
+ "method",
+ "payload",
+ "invoice_id",
+ "preview"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 192,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.vfd_providers.doctype.simplify_vfd_settings.simplify_vfd_settings.get_bearer_token",
+ "args": [
+ "self"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 33,
+ "in_class": "SimplifyVFDSettings"
+ },
+ {
+ "path": "csf_tz.vfd_providers.doctype.total_vfd_setting.total_vfd_setting.post_fiscal_receipt",
+ "args": [
+ "doc",
+ "method",
+ "payload",
+ "invoice_id",
+ "preview"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 112,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.vfd_providers.doctype.vfdplus_settings.vfdplus_settings.post_fiscal_receipt",
+ "args": [
+ "doc",
+ "method",
+ "payload",
+ "invoice_id",
+ "preview"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 224,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.vfd_providers.doctype.vfdplus_settings.vfdplus_settings.get_account_info",
+ "args": [
+ "company"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 335,
+ "in_class": null
+ },
+ {
+ "path": "csf_tz.vfd_support.utils.generate_tra_vfd",
+ "args": [
+ "docname",
+ "sinv_doc",
+ "method",
+ "caller"
+ ],
+ "decorator": "frappe.whitelist()",
+ "line": 23,
+ "in_class": null
+ }
+]
\ No newline at end of file