From cf4f43f20deff617195df2f4d75b7d991cf7dc30 Mon Sep 17 00:00:00 2001 From: casy Date: Tue, 10 Mar 2026 19:50:04 +0200 Subject: [PATCH 1/3] feature:buyer --- fiscguy/api.py | 15 ++++++ fiscguy/management/commands/init_device.py | 6 ++- fiscguy/migrations/0001_initial.py | 2 +- ...email_remove_buyer_phonenumber_and_more.py | 25 ---------- ...mail_buyer_phonenumber_buyer_trade_name.py | 31 ------------ fiscguy/models.py | 10 +--- fiscguy/serializers.py | 48 +++++++++++-------- fiscguy/services/receipt_service.py | 3 ++ fiscguy/urls.py | 2 + fiscguy/views.py | 13 +++++ fiscguy/zimra_base.py | 8 ---- fiscguy/zimra_receipt_handler.py | 31 +++++++----- 12 files changed, 85 insertions(+), 109 deletions(-) delete mode 100644 fiscguy/migrations/0002_remove_buyer_email_remove_buyer_phonenumber_and_more.py delete mode 100644 fiscguy/migrations/0003_buyer_email_buyer_phonenumber_buyer_trade_name.py diff --git a/fiscguy/api.py b/fiscguy/api.py index 756ed1e..64e3ed7 100644 --- a/fiscguy/api.py +++ b/fiscguy/api.py @@ -218,6 +218,20 @@ def get_taxes() -> list: return TaxSerializer(taxes, many=True).data +def get_ping() -> int: + """ + Get ping details from zimra + + Retturns: + { + deviceID (str), + reportingFrequency (int) + } + """ + client = _get_client() + return client.ping() + + # module-level shortcuts __all__ = [ "open_day", @@ -226,4 +240,5 @@ def get_taxes() -> list: "submit_receipt", "get_configuration", "get_taxes", + "ping", ] diff --git a/fiscguy/management/commands/init_device.py b/fiscguy/management/commands/init_device.py index d1dae89..d5b4642 100644 --- a/fiscguy/management/commands/init_device.py +++ b/fiscguy/management/commands/init_device.py @@ -62,8 +62,10 @@ def handle(self, *args, **options): print("*" * 75) print("\nDeveloped by Casper Moyo") print("Version 0.1.4\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: " diff --git a/fiscguy/migrations/0001_initial.py b/fiscguy/migrations/0001_initial.py index fc2ef74..04c76a7 100644 --- a/fiscguy/migrations/0001_initial.py +++ b/fiscguy/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.2 on 2026-02-18 07:32 +# Generated by Django 5.2 on 2026-03-10 15:31 import django.db.models.deletion from django.db import migrations, models 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 deleted file mode 100644 index bd6ab82..0000000 --- a/fiscguy/migrations/0002_remove_buyer_email_remove_buyer_phonenumber_and_more.py +++ /dev/null @@ -1,25 +0,0 @@ -# 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 deleted file mode 100644 index f15c6d4..0000000 --- a/fiscguy/migrations/0003_buyer_email_buyer_phonenumber_buyer_trade_name.py +++ /dev/null @@ -1,31 +0,0 @@ -# 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/models.py b/fiscguy/models.py index e0242b2..73a040a 100644 --- a/fiscguy/models.py +++ b/fiscguy/models.py @@ -1,4 +1,4 @@ -from django.db import models +from django.db import models, transaction class Device(models.Model): @@ -191,14 +191,6 @@ class ReceiptType(models.TextChoices): credit_note_reason = models.CharField(max_length=255, null=True, blank=True) credit_note_reference = models.CharField(max_length=255, null=True, blank=True) - def save(self, *args, **kwargs): - is_new = self.pk is None - super().save(*args, **kwargs) - - if is_new and not self.receipt_number: - self.receipt_number = f"R-{self.id:06d}" - super().save(update_fields=["receipt_number"]) - def __str__(self): return f"Receipt No: {self.receipt_number} | Type: {self.receipt_type} | Total: {self.total_amount}" diff --git a/fiscguy/serializers.py b/fiscguy/serializers.py index 54ae839..442710e 100644 --- a/fiscguy/serializers.py +++ b/fiscguy/serializers.py @@ -92,6 +92,7 @@ class Meta: class ReceiptCreateSerializer(serializers.ModelSerializer): lines = ReceiptLineCreateSerializer(many=True) + buyer = BuyerSerializer(required=False, allow_null=True) credit_note_reference = serializers.CharField(required=False, allow_blank=True) credit_note_reason = serializers.CharField(required=False, allow_blank=True) @@ -102,7 +103,7 @@ class Meta: "receipt_type", "total_amount", "currency", - # "buyer", + "buyer", "lines", "payment_terms", "credit_note_reference", @@ -134,35 +135,40 @@ def validate(self, attrs): return attrs def create(self, validated_data): - # if buyer_data: - # buyer_data = validated_data.pop("buyer") + buyer_data = validated_data.pop("buyer", None) 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."} - ) + buyer = None + + if buyer_data: + # validate tin number + if len(buyer_data["tin_number"]) != 10: + raise serializers.ValidationError( + {"buyer": "Tin number is incorrect, must be ten digit."} + ) - buyer = Buyer.objects.get_or_create( - tin_number=buyer_data["tin_number"].strip(), - defaults={ - "name": buyer_data["name"].strip(), # registered name - "emai": buyer_data["email"].strip(), - "trade_name": buyer_data["trade_name"].strip(), # trade name e.g branch name - "phonenumber": buyer_data["phonenumber"].strip(), - "address": buyer_data["address"].strip(), - }, - )""" + buyer, _ = Buyer.objects.get_or_create( + tin_number=buyer_data["tin_number"].strip(), + defaults={ + "name": buyer_data["name"].strip(), # business registered name + "email": buyer_data["email"].strip(), + "trade_name": buyer_data[ + "trade_name" + ].strip(), # trade name e.g branch name + "phonenumber": buyer_data["phonenumber"].strip(), + "address": buyer_data["address"].strip(), + }, + ) receipt = Receipt.objects.create(**validated_data) - # receipt.buyer = buyer - # sreceipt.save() + + if buyer: + receipt.buyer = buyer + receipt.save() for idx, line_data in enumerate(lines_data): diff --git a/fiscguy/services/receipt_service.py b/fiscguy/services/receipt_service.py index 7cc7587..1921f1d 100644 --- a/fiscguy/services/receipt_service.py +++ b/fiscguy/services/receipt_service.py @@ -48,6 +48,9 @@ def create_and_submit_receipt( # Assign global number receipt.global_number = receipt_data["receipt_data"]["receiptGlobalNo"] + # Assign global number to receipt number + receipt.receipt_number = f"R-{receipt.global_number:08d}" + # Update counters self.receipt_handler._update_fiscal_counters(receipt, receipt_data["receipt_data"]) diff --git a/fiscguy/urls.py b/fiscguy/urls.py index 46b6404..59b05ba 100644 --- a/fiscguy/urls.py +++ b/fiscguy/urls.py @@ -5,6 +5,7 @@ BuyerViewset, CloseDayView, ConfigurationView, + DevicePing, GetStatusView, OpenDayView, ReceiptView, @@ -17,6 +18,7 @@ app_name = "fiscguy" urlpatterns = [ + path("get-ping/", DevicePing.as_view(), name="ping"), path("taxes/", TaxView.as_view(), name="taxes"), path("open-day/", OpenDayView.as_view(), name="open"), path("close-day/", CloseDayView.as_view(), name="close"), diff --git a/fiscguy/views.py b/fiscguy/views.py index aa31952..6970c2b 100644 --- a/fiscguy/views.py +++ b/fiscguy/views.py @@ -18,6 +18,7 @@ from fiscguy.api import ( close_day, get_configuration, + get_ping, get_status, get_taxes, open_day, @@ -116,6 +117,18 @@ def get(self, request): return Response({"error": str(e)}, status=400) +class DevicePing(APIView): + """End point foor device ping""" + + def get(self, request): + try: + response = get_ping() + return Response(response, status=status.HTTP_200_OK) + except Exception as e: + logger.exception("Ping failed") + return Response({"error": str(e)}, status=400) + + class OpenDayView(APIView): """REST endpoint to open a fiscal day. diff --git a/fiscguy/zimra_base.py b/fiscguy/zimra_base.py index f5ba87e..707fe3e 100644 --- a/fiscguy/zimra_base.py +++ b/fiscguy/zimra_base.py @@ -150,17 +150,9 @@ def submit_receipt(self, receipt_payload: dict, hash_value: str, signature: str) "signature": signature, } - logger.info(f"Submitting receipt: {receipt_payload}") - return self._request("POST", "SubmitReceipt", json=receipt_payload).json() def ping(self) -> dict: - """ - is used to report device is online to FDMS - returns: - deviceID (str), - reportingFrequency (int) - """ return self._request("GET", "ping") def close(self): diff --git a/fiscguy/zimra_receipt_handler.py b/fiscguy/zimra_receipt_handler.py index 624594a..7e53bbc 100644 --- a/fiscguy/zimra_receipt_handler.py +++ b/fiscguy/zimra_receipt_handler.py @@ -9,6 +9,7 @@ from decimal import Decimal from io import BytesIO from time import sleep +from typing import Any, Dict, List import qrcode from django.core.files.base import ContentFile @@ -65,7 +66,7 @@ def client(self): self._client = None return self._client - def _get_receipt_global_number(self, receipt): + def _get_receipt_global_number(self, receipt: Receipt) -> int: """ Fetch the last receiptGlobalNo from Receipts. @@ -75,9 +76,10 @@ def _get_receipt_global_number(self, receipt): last_receipt = Receipt.objects.exclude(id=receipt.id).order_by("-created_at").first() last_global_no = last_receipt.global_number if last_receipt else 0 new_global_no = int(last_global_no) + 1 + return new_global_no - def generate_receipt_data(self, receipt: Receipt, receipt_items: list): + def generate_receipt_data(self, receipt, receipt_items: List) -> Dict[str, Any]: """ Transform receipt data to ZIMRA receipt format. Supports FiscalInvoice and CreditNote. @@ -127,11 +129,6 @@ def generate_receipt_data(self, receipt: Receipt, receipt_items: list): } ) - # buyer data according to zimra only registered name and buyer tin are mandatory - buyerData = [ - {"buyerRegisteredName": receipt.buyer.name, "buyerTIN": receipt.buyer.tin_number} - ] - # Build receipt lines for index, item in enumerate(receipt_items, start=1): @@ -195,13 +192,12 @@ def generate_receipt_data(self, receipt: Receipt, receipt_items: list): "receiptCurrency": receipt.currency.upper(), "receiptCounter": fiscal_day.receipt_counter + 1, "receiptGlobalNo": self._get_receipt_global_number(receipt), - "invoiceNo": receipt.receipt_number, + "invoiceNo": f"R-{self._get_receipt_global_number(receipt)}", "receiptNotes": ( receipt.credit_note_reason if is_credit_note else "Thank you for shopping with us!" ), - "buyerData": buyerData, "receiptDate": timestamp(), "receiptLinesTaxInclusive": True, "receiptLines": receipt_lines, @@ -225,6 +221,13 @@ def generate_receipt_data(self, receipt: Receipt, receipt_items: list): receipt_data["creditDebitNote"] = {"receiptID": original_receipt.zimra_inv_id} + # buyer data according to zimra only registered name and buyer tin are mandatory + if receipt.buyer: + receipt_data["buyerData"] = { + "buyerRegisterName": receipt.buyer.name, + "buyerTIN": receipt.buyer.tin_number, + } + # Generate signature string signature_string = self.crypto.generate_receipt_signature_string( device_id=self.device.device_id, @@ -246,7 +249,9 @@ def generate_receipt_data(self, receipt: Receipt, receipt_items: list): logger.exception("Error generating receipt data") return {"error": str(e)} - def submit_receipt(self, hash_value, signature, receipt_data): + def submit_receipt( + self, hash_value: str, signature: str, receipt_data: Dict[str, Any] + ) -> Dict[str, Any]: """ Submit receipt to ZIMRA and handle offline storage, QR code generation, and fiscal counters. @@ -278,7 +283,9 @@ def submit_receipt(self, hash_value, signature, receipt_data): logger.error(f"Error in submit_receipt_with_storage: {e}") return {"error": str(e)} - def _generate_qr_code(self, receipt, receipt_data, signature): + def _generate_qr_code( + self, receipt: Receipt, receipt_data: Dict[str, Any], signature: str + ) -> None: """ Generate and save QR code for invoice. @@ -328,7 +335,7 @@ def _generate_qr_code(self, receipt, receipt_data, signature): logger.error(f"Error generating QR code: {e}") raise - def _update_fiscal_counters(self, receipt, receipt_data): + def _update_fiscal_counters(self, receipt: dict, receipt_data: dict) -> None: """ Update fiscal counters based on receipt data. From 66049e44c397e9cbdcd027511c18442f322b597b Mon Sep 17 00:00:00 2001 From: casy Date: Tue, 10 Mar 2026 19:58:42 +0200 Subject: [PATCH 2/3] flake8, ping --- fiscguy/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fiscguy/api.py b/fiscguy/api.py index 64e3ed7..41e3177 100644 --- a/fiscguy/api.py +++ b/fiscguy/api.py @@ -240,5 +240,5 @@ def get_ping() -> int: "submit_receipt", "get_configuration", "get_taxes", - "ping", + "get_ping", ] From 859a25780aef46d5a5270effd318e96602355530 Mon Sep 17 00:00:00 2001 From: casy Date: Tue, 10 Mar 2026 20:29:54 +0200 Subject: [PATCH 3/3] chore: buyer tests dict --- fiscguy/tests/test_api.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/fiscguy/tests/test_api.py b/fiscguy/tests/test_api.py index cde1338..6f5bb3d 100644 --- a/fiscguy/tests/test_api.py +++ b/fiscguy/tests/test_api.py @@ -83,6 +83,7 @@ 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 @@ -113,10 +114,8 @@ def test_get_status_success(self, mock_client_class): } mock_client.get_status.return_value = expected_status - # Call the API result = api.get_status() - # Assertions self.assertEqual(result, expected_status) mock_client.get_status.assert_called_once() @@ -222,7 +221,6 @@ def test_close_day_success(self, mock_client_class): result = api.close_day() - # Assertions self.assertEqual(result, expected_status) mock_client.close_day.assert_called_once() mock_client.get_status.assert_called_once() @@ -302,14 +300,15 @@ def test_submit_receipt_success(self, mock_handler_class): "tax_name": "standard rated 15.5%", } ], - "buyer": [], + "buyer": { + "buyerRegisterName": self.buyer.name, + "buyerTIN": self.buyer.tin_number, + }, } result = api.submit_receipt(receipt_payload) - # Assertions self.assertIsNotNone(result) - # Verify receipt was created in DB self.assertTrue(Receipt.objects.filter(receipt_type="fiscalinvoice").exists()) def test_submit_receipt_invalid_tax_name_raises(self): @@ -328,11 +327,13 @@ def test_submit_receipt_invalid_tax_name_raises(self): "tax_name": "nonexistent tax type", } ], - "buyer": [], + "buyer": { + "buyerRegisterName": self.buyer.name, + "buyerTIN": self.buyer.tin_number, + }, } with self.assertRaises(Exception): - # Should raise during tax resolution api.submit_receipt(receipt_payload) @patch("fiscguy.api.ZIMRAReceiptHandler") @@ -387,7 +388,10 @@ def test_submit_receipt_with_multiple_tax_types(self, mock_handler_class): "tax_name": "exempt", }, ], - "buyer": [], + "buyer": { + "buyerRegisterName": self.buyer.name, + "buyerTIN": self.buyer.tin_number, + }, } result = api.submit_receipt(receipt_payload)