diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..4fcf68e --- /dev/null +++ b/.flake8 @@ -0,0 +1,8 @@ +[flake8] +max-line-length = 120 +exclude = + .git, + __pycache__, + build, + dist +select = E9,F63,F7,F82 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b469592..d6497a9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,26 +15,26 @@ jobs: steps: - uses: actions/checkout@v3 - + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - + - name: Install dependencies run: | python -m pip install --upgrade pip pip install -e ".[dev]" - + - name: Lint with flake8 run: | flake8 fiscguy --count --select=E9,F63,F7,F82 --show-source --statistics flake8 fiscguy --count --exit-zero --max-complexity=10 --max-line-length=100 --statistics - + - name: Test with pytest run: | - pytest --cov=fiscguy --cov-report=xml --cov-report=term-missing - + pytest fiscguy/tests --cov=fiscguy --cov-report=xml --cov-report=term-missing + - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: @@ -46,21 +46,21 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - + - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.11' - - - name: Install dependencies + + - name: Install formatting tools run: | python -m pip install --upgrade pip pip install black isort - + - name: Check code formatting with Black run: | black --check fiscguy - + - name: Check import sorting with isort run: | isort --check-only fiscguy diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..9aa1cee --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +repos: + - repo: https://github.com/pre-commit/mirrors-isort + rev: v5.10.1 + hooks: + - id: isort + name: isort (python import sorter) + entry: isort + language: system + types: [python] + + - repo: https://github.com/psf/black + rev: 23.9.1 + hooks: + - id: black + name: black (python code formatter) + language: system + types: [python] diff --git a/CHANGELOG.md b/CHANGELOG.md index 159f2d4..6404785 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ buyer feature crud via endpoint and via api (a user can now attach buyer data on - ZIMRA online heartbeat scheduler - Background ping execution without Redis - Engine-level scheduled task module (tasks.py) +- flake8 config ### Changed - Internal structure of ping_device diff --git a/fiscguy/api.py b/fiscguy/api.py index 0ca1536..756ed1e 100644 --- a/fiscguy/api.py +++ b/fiscguy/api.py @@ -13,18 +13,16 @@ providing a clean library interface for both API and programmatic use. """ -from typing import Dict, Any +from typing import Any, Dict + from loguru import logger -from fiscguy.models import Device, FiscalDay, Taxes +from fiscguy.models import Configuration, Device, FiscalDay, Taxes +from fiscguy.serializers import ConfigurationSerializer, TaxSerializer from fiscguy.services.closing_day_service import ClosingDayService from fiscguy.services.receipt_service import ReceiptService from fiscguy.zimra_base import ZIMRAClient from fiscguy.zimra_receipt_handler import ZIMRAReceiptHandler -from fiscguy.models import Configuration -from fiscguy.serializers import ConfigurationSerializer -from fiscguy.serializers import TaxSerializer - # Module-level instances _device = None diff --git a/fiscguy/management/commands/init_device.py b/fiscguy/management/commands/init_device.py index ad42d68..0322946 100644 --- a/fiscguy/management/commands/init_device.py +++ b/fiscguy/management/commands/init_device.py @@ -7,8 +7,8 @@ from loguru import logger from fiscguy.models import Certs, Device, Taxes -from fiscguy.zimra_crypto import ZIMRACrypto from fiscguy.services.configuration_service import create_or_update_config +from fiscguy.zimra_crypto import ZIMRACrypto """ Management command to register a ZIMRA fiscal device and fetch its @@ -62,10 +62,8 @@ def handle(self, *args, **options): print("*" * 75) print("\nDeveloped by Casper Moyo") print("Version 0.1.5\n") - print( - "Welcome to device registration please input the following provided\ - information as proveded by ZIMRA\n" - ) + print("Welcome to device registration please input the following provided\ + information as proveded by ZIMRA\n") environment = input( "Enter yes for production environment and no for test enviroment: " @@ -193,11 +191,11 @@ def delete_all_test_data(self) -> None: """ try: from fiscguy.models import ( - FiscalDay, + Configuration, FiscalCounter, + FiscalDay, Receipt, ReceiptLine, - Configuration, ) with transaction.atomic(): diff --git a/fiscguy/management/commands/ping_zimra.py b/fiscguy/management/commands/ping_zimra.py index 7d066c1..600d91c 100644 --- a/fiscguy/management/commands/ping_zimra.py +++ b/fiscguy/management/commands/ping_zimra.py @@ -1,19 +1,21 @@ -from django.core.management.base import BaseCommand -from zimra_base import ZIMRAClient import time -from fiscguy.models import Device + +from django.core.management.base import BaseCommand from loguru import logger +from zimra_base import ZIMRAClient + from fiscguy.api import _get_client +from fiscguy.models import Device class Command(BaseCommand): help = "Ping ZIMRA periodically" - def handle(self, *arg, **optioons): + def handle(self, *arg, **optioons): logger.info("Ping for Initiatiated") while True: try: - res = _get_client().ping() - time.sleep(res['reportingFrequency']) + res = _get_client().ping() + time.sleep(res["reportingFrequency"]) except Exception as e: logger.error(f"Failed to ping zimra: {e}") diff --git a/fiscguy/migrations/0001_initial.py b/fiscguy/migrations/0001_initial.py new file mode 100644 index 0000000..fc2ef74 --- /dev/null +++ b/fiscguy/migrations/0001_initial.py @@ -0,0 +1,271 @@ +# Generated by Django 6.0.2 on 2026-02-18 07:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Buyer", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("name", models.CharField(max_length=255)), + ("address", models.CharField(max_length=255)), + ("phonenumber", models.CharField(max_length=20)), + ("trade_name", models.CharField(max_length=100)), + ("tin_number", models.CharField(max_length=255)), + ("email", models.EmailField(max_length=255)), + ], + ), + migrations.CreateModel( + name="Certs", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("csr", models.TextField()), + ("certificate", models.TextField()), + ("certificate_key", models.TextField()), + ("production", models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name="Configuration", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("tax_payer_name", models.CharField(max_length=255)), + ("tax_inclusive", models.BooleanField(default=True)), + ("tin_number", models.CharField(max_length=20)), + ("vat_number", models.CharField(max_length=20)), + ("address", models.CharField(max_length=255)), + ("phone_number", models.CharField(max_length=20)), + ("email", models.EmailField(max_length=254)), + ("url", models.URLField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name="Device", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("org_name", models.CharField(max_length=255)), + ("activation_key", models.CharField(max_length=255)), + ("device_id", models.CharField(max_length=100, unique=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("device_model_name", models.CharField(max_length=255, null=True)), + ("device_serial_number", models.CharField(max_length=255, null=True)), + ("device_model_version", models.CharField(max_length=255, null=True)), + ("production", models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name="FiscalDay", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("day_no", models.IntegerField()), + ("receipt_counter", models.IntegerField()), + ("is_open", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name="Taxes", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("code", models.CharField(max_length=10)), + ("name", models.CharField(max_length=100)), + ("tax_id", models.IntegerField()), + ("percent", models.FloatField()), + ], + ), + migrations.CreateModel( + name="FiscalCounter", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "fiscal_counter_type", + models.CharField( + choices=[ + ("SaleByTax", "Sale_by_Tax"), + ("SaleTaxByTax", "Sale Tax by Tax"), + ("CreditNoteByTax", "Credit Note by Tax"), + ("CreditNoteTaxByTax", "Credit Note Tax by Tax"), + ("DebitNoteByTax", "Debit Note by Tax"), + ("DebitNoteTaxByTax", "Debit Note Tax by Tax"), + ("BalanceByMoneyType", "Balance by Money Type"), + ("Other", "Other"), + ], + default="SaleByTax", + max_length=30, + ), + ), + ( + "fiscal_counter_currency", + models.CharField( + choices=[("USD", "usd"), ("ZWG", "zwg")], default="USD", max_length=10 + ), + ), + ( + "fiscal_counter_tax_percent", + models.DecimalField( + blank=True, decimal_places=2, default=0.0, max_digits=5, null=True + ), + ), + ("fiscal_counter_tax_id", models.IntegerField(default=3)), + ( + "fiscal_counter_money_type", + models.CharField( + choices=[ + ("Cash", "Cash"), + ("Card", "Card"), + ("BankTransfer", "Bank Transfer"), + ("MobileMoney", "Mobile Money"), + ], + default="Cash", + max_length=20, + null=True, + ), + ), + ( + "fiscal_counter_value", + models.DecimalField(decimal_places=2, default=0.0, max_digits=10, null=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "fiscal_day", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="counters", + to="fiscguy.fiscalday", + ), + ), + ], + ), + migrations.CreateModel( + name="Receipt", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "receipt_number", + models.CharField(blank=True, max_length=255, null=True, unique=True), + ), + ( + "receipt_type", + models.CharField( + choices=[ + ("fiscalinvoice", "Fiscal Invoice"), + ("creditnote", "Creditnote"), + ("debitnote", "Debitnote"), + ], + default="fiscalinvoice", + max_length=255, + ), + ), + ("total_amount", models.FloatField()), + ("qr_code", models.ImageField(blank=True, null=True, upload_to="Zimra_qr_codes")), + ("code", models.CharField(blank=True, max_length=20, null=True)), + ( + "currency", + models.CharField( + choices=[("USD", "usd"), ("ZWG", "zwg")], default="USD", max_length=255 + ), + ), + ("global_number", models.IntegerField(blank=True, null=True)), + ("hash_value", models.CharField(blank=True, max_length=255, null=True)), + ("signature", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now_add=True)), + ("zimra_inv_id", models.CharField(max_length=255, null=True)), + ("payment_terms", models.CharField(max_length=200)), + ("submitted", models.BooleanField(default=False, null=True)), + ("is_credit_note", models.BooleanField(default=False, null=True)), + ("credit_note_reason", models.CharField(blank=True, max_length=255, null=True)), + ("credit_note_reference", models.CharField(blank=True, max_length=255, null=True)), + ( + "buyer", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="receipts", + to="fiscguy.buyer", + ), + ), + ], + ), + migrations.CreateModel( + name="ReceiptLine", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("product", models.CharField(max_length=255)), + ("quantity", models.IntegerField()), + ("unit_price", models.FloatField()), + ("line_total", models.FloatField()), + ("tax_amount", models.FloatField()), + ( + "receipt", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="lines", + to="fiscguy.receipt", + ), + ), + ( + "tax_type", + models.ForeignKey( + null=True, on_delete=django.db.models.deletion.CASCADE, to="fiscguy.taxes" + ), + ), + ], + ), + ] diff --git a/fiscguy/migrations/0002_remove_buyer_email_remove_buyer_phonenumber_and_more.py b/fiscguy/migrations/0002_remove_buyer_email_remove_buyer_phonenumber_and_more.py new file mode 100644 index 0000000..bd6ab82 --- /dev/null +++ b/fiscguy/migrations/0002_remove_buyer_email_remove_buyer_phonenumber_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.2 on 2026-02-18 07:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("fiscguy", "0001_initial"), + ] + + operations = [ + migrations.RemoveField( + model_name="buyer", + name="email", + ), + migrations.RemoveField( + model_name="buyer", + name="phonenumber", + ), + migrations.RemoveField( + model_name="buyer", + name="trade_name", + ), + ] diff --git a/fiscguy/migrations/0003_buyer_email_buyer_phonenumber_buyer_trade_name.py b/fiscguy/migrations/0003_buyer_email_buyer_phonenumber_buyer_trade_name.py new file mode 100644 index 0000000..f15c6d4 --- /dev/null +++ b/fiscguy/migrations/0003_buyer_email_buyer_phonenumber_buyer_trade_name.py @@ -0,0 +1,31 @@ +# Generated by Django 6.0.2 on 2026-02-18 07:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("fiscguy", "0002_remove_buyer_email_remove_buyer_phonenumber_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="buyer", + name="email", + field=models.EmailField(default="cas@s.com", max_length=255), + preserve_default=False, + ), + migrations.AddField( + model_name="buyer", + name="phonenumber", + field=models.CharField(default="2", max_length=20), + preserve_default=False, + ), + migrations.AddField( + model_name="buyer", + name="trade_name", + field=models.CharField(default="s", max_length=100), + preserve_default=False, + ), + ] diff --git a/fiscguy/serializers.py b/fiscguy/serializers.py index c979a1a..54ae839 100644 --- a/fiscguy/serializers.py +++ b/fiscguy/serializers.py @@ -1,5 +1,5 @@ -from rest_framework import serializers from django.db import transaction +from rest_framework import serializers from fiscguy.models import Buyer, Configuration, Receipt, ReceiptLine, Taxes from fiscguy.zimra_receipt_handler import ZIMRAReceiptHandler @@ -102,7 +102,7 @@ class Meta: "receipt_type", "total_amount", "currency", - "buyer", + # "buyer", "lines", "payment_terms", "credit_note_reference", @@ -134,13 +134,16 @@ def validate(self, attrs): return attrs def create(self, validated_data): - buyer_data = validated_data.pop("buyer") + # if buyer_data: + # buyer_data = validated_data.pop("buyer") + lines_data = validated_data.pop("lines") receipt_type = validated_data.get("receipt_type", "").lower() with transaction.atomic(): # validate tin number + """ if len(buyer_data["tin_number"]) != 10: raise serializers.ValidationError( {"buyer": "Tin number is incorrect, must be ten digit."} @@ -155,11 +158,11 @@ def create(self, validated_data): "phonenumber": buyer_data["phonenumber"].strip(), "address": buyer_data["address"].strip(), }, - ) + )""" receipt = Receipt.objects.create(**validated_data) - receipt.buyer = buyer - receipt.save() + # receipt.buyer = buyer + # sreceipt.save() for idx, line_data in enumerate(lines_data): diff --git a/fiscguy/services/closing_day_service.py b/fiscguy/services/closing_day_service.py index c4ad383..33af9da 100644 --- a/fiscguy/services/closing_day_service.py +++ b/fiscguy/services/closing_day_service.py @@ -1,11 +1,12 @@ from collections import defaultdict from typing import Any, Dict, Iterable, List, Tuple + from django.utils.timezone import now +from loguru import logger + from fiscguy.models import Device, FiscalCounter, FiscalDay from fiscguy.utils.datetime_now import date_today as today -from loguru import logger - SALE_BY_TAX_ORDER: Tuple[str, ...] = ("exempt", "zero", "standard") SALE_TAX_BY_TAX_ORDER: Tuple[str, ...] = ("zero", "standard") CREDIT_BY_TAX_ORDER: Tuple[str, ...] = ("exempt", "zero", "standard") diff --git a/fiscguy/services/configuration_service.py b/fiscguy/services/configuration_service.py index 8e0a4da..3980002 100644 --- a/fiscguy/services/configuration_service.py +++ b/fiscguy/services/configuration_service.py @@ -1,5 +1,6 @@ -from loguru import logger from django.db import transaction +from loguru import logger + from fiscguy.models import Configuration, Taxes diff --git a/fiscguy/services/receipt_service.py b/fiscguy/services/receipt_service.py index 628d768..7cc7587 100644 --- a/fiscguy/services/receipt_service.py +++ b/fiscguy/services/receipt_service.py @@ -1,4 +1,5 @@ -from typing import Tuple, Dict, Any +from typing import Any, Dict, Tuple + from django.db import transaction from loguru import logger diff --git a/fiscguy/tests/__initt__.py b/fiscguy/tests/__init__.py similarity index 100% rename from fiscguy/tests/__initt__.py rename to fiscguy/tests/__init__.py diff --git a/fiscguy/tests/conftest.py b/fiscguy/tests/conftest.py new file mode 100644 index 0000000..d3fe226 --- /dev/null +++ b/fiscguy/tests/conftest.py @@ -0,0 +1,24 @@ +import django +from django.conf import settings + + +def pytest_configure(): + if not settings.configured: + settings.configure( + INSTALLED_APPS=[ + "django.contrib.contenttypes", + "fiscguy", + ], + DATABASES={ + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } + }, + SECRET_KEY="fake-key-for-tests", + ) + django.setup() + + from django.core.management import call_command + + call_command("migrate", run_syncdb=True, verbosity=0) diff --git a/fiscguy/tests/test_api.py b/fiscguy/tests/test_api.py index c21fbe4..cde1338 100644 --- a/fiscguy/tests/test_api.py +++ b/fiscguy/tests/test_api.py @@ -8,19 +8,20 @@ from decimal import Decimal from unittest.mock import MagicMock, patch + from django.test import TestCase +from fiscguy import api from fiscguy.models import ( + Buyer, + Certs, + Configuration, Device, - FiscalDay, FiscalCounter, + FiscalDay, Receipt, Taxes, - Configuration, - Buyer, - Certs, ) -from fiscguy import api class APILibraryTestSetup(TestCase): @@ -82,7 +83,6 @@ def setUp(self): self.buyer = Buyer.objects.create( name="Test Buyer", tin_number="1234567890", - vat_numberr="VAT-BUYER-001", ) # Reset module-level caches to avoid pollution between tests @@ -302,7 +302,7 @@ def test_submit_receipt_success(self, mock_handler_class): "tax_name": "standard rated 15.5%", } ], - "buyer": self.buyer.id, + "buyer": [], } result = api.submit_receipt(receipt_payload) @@ -328,7 +328,7 @@ def test_submit_receipt_invalid_tax_name_raises(self): "tax_name": "nonexistent tax type", } ], - "buyer": self.buyer.id, + "buyer": [], } with self.assertRaises(Exception): @@ -387,7 +387,7 @@ def test_submit_receipt_with_multiple_tax_types(self, mock_handler_class): "tax_name": "exempt", }, ], - "buyer": self.buyer.id, + "buyer": [], } result = api.submit_receipt(receipt_payload) diff --git a/fiscguy/urls.py b/fiscguy/urls.py index a8ff379..46b6404 100644 --- a/fiscguy/urls.py +++ b/fiscguy/urls.py @@ -1,14 +1,15 @@ from django.urls import path +from rest_framework.routers import DefaultRouter + from .views import ( + BuyerViewset, CloseDayView, ConfigurationView, GetStatusView, OpenDayView, ReceiptView, TaxView, - BuyerViewset, ) -from rest_framework.routers import DefaultRouter router = DefaultRouter() router.register(r"buyer/", BuyerViewset, basename="buyer") diff --git a/fiscguy/utils/cert_temp_manager.py b/fiscguy/utils/cert_temp_manager.py index d68e75a..23a89ff 100644 --- a/fiscguy/utils/cert_temp_manager.py +++ b/fiscguy/utils/cert_temp_manager.py @@ -16,7 +16,7 @@ def __init__(self, cert_pem: str, key_pem: str): self._pem_path = self._temp_dir / "client.pem" self._key_path = self._temp_dir / "key.pem" - self._pem_path.write_text(f"{cert_pem}\{key_pem}") + self._pem_path.write_text(rf"{cert_pem}\{key_pem}") self._key_path.write_text(f"{key_pem}") self._closed = False diff --git a/fiscguy/views.py b/fiscguy/views.py index cb9e492..aa31952 100644 --- a/fiscguy/views.py +++ b/fiscguy/views.py @@ -16,15 +16,15 @@ # Import public library functions from fiscguy.api import ( - open_day, close_day, - get_status, - submit_receipt, get_configuration, + get_status, get_taxes, + open_day, + submit_receipt, ) -from fiscguy.models import Receipt, Buyer -from fiscguy.serializers import ReceiptSerializer, BuyerSerializer +from fiscguy.models import Buyer, Receipt +from fiscguy.serializers import BuyerSerializer, ReceiptSerializer class ReceiptView(generics.GenericAPIView): diff --git a/pyproject.toml b/pyproject.toml index b55abf7..56844f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,10 +58,10 @@ dev = [ ] [project.urls] -Homepage = "https://github.com/cassymyo-spec/zimra" -Documentation = "https://github.com/cassymyo-spec/zimra#readme" -Repository = "https://github.com/cassymyo-spec/zimra.git" -Issues = "https://github.com/cassymyo-spec/zimra/issues" +Homepage = "https://github.com/digitaltouchcode/fisc" +Documentation = "https://github.com/digitaltouchcode/fisc#readme" +Repository = "https://github.com/digitaltouchcode/fisc.git" +Issues = "https://github.com/digitaltouchcode/issues" [tool.setuptools] packages = ["fiscguy"] @@ -97,7 +97,6 @@ exclude = ''' [tool.isort] profile = "black" -multi_line_mode = 3 include_trailing_comma = true force_grid_wrap = 0 use_parentheses = true