From f05ba36d3b3932b67590d03a992cec97dec0d0c3 Mon Sep 17 00:00:00 2001 From: casy Date: Thu, 12 Mar 2026 19:14:42 +0200 Subject: [PATCH 1/3] Feature: multiple payments --- fiscguy/api.py | 8 ++ fiscguy/management/commands/init_device.py | 6 +- ...pt_payment_terms_receipt_payment_method.py | 34 ++++++ ...me_payment_method_receipt_payment_terms.py | 18 +++ fiscguy/models.py | 2 +- fiscguy/serializers.py | 1 + fiscguy/services/closing_day_service.py | 29 +++-- fiscguy/tests/test_api.py | 106 +++++++++++++++++- fiscguy/zimra_receipt_handler.py | 7 +- 9 files changed, 190 insertions(+), 21 deletions(-) create mode 100644 fiscguy/migrations/0002_remove_receipt_payment_terms_receipt_payment_method.py create mode 100644 fiscguy/migrations/0003_rename_payment_method_receipt_payment_terms.py diff --git a/fiscguy/api.py b/fiscguy/api.py index 41e3177..5659368 100644 --- a/fiscguy/api.py +++ b/fiscguy/api.py @@ -151,6 +151,14 @@ def submit_receipt(receipt_data: Dict[str, Any]) -> Dict[str, Any]: "receipt_type": str, "currency": str, "total_amount": float, + "payment_terms": str, + "buyer": { + "name": str, + "address": str, + "phonenumber": str, + "tin_number": str, + "email": str, + }, "lines": [ { "product": str, 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/0002_remove_receipt_payment_terms_receipt_payment_method.py b/fiscguy/migrations/0002_remove_receipt_payment_terms_receipt_payment_method.py new file mode 100644 index 0000000..66f868f --- /dev/null +++ b/fiscguy/migrations/0002_remove_receipt_payment_terms_receipt_payment_method.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2 on 2026-03-12 15:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("fiscguy", "0001_initial"), + ] + + operations = [ + migrations.RemoveField( + model_name="receipt", + name="payment_terms", + ), + migrations.AddField( + model_name="receipt", + name="payment_method", + field=models.CharField( + choices=[ + ("Cash", "Cash"), + ("Card", "Card"), + ("MobileWallet", "Mobile Wallet"), + ("BankTransfer", "Bank Transfer"), + ("Coupon", "Coupon"), + ("Credit", "Credit"), + ("Other", "Other"), + ], + default="Cash", + max_length=20, + ), + ), + ] diff --git a/fiscguy/migrations/0003_rename_payment_method_receipt_payment_terms.py b/fiscguy/migrations/0003_rename_payment_method_receipt_payment_terms.py new file mode 100644 index 0000000..877be49 --- /dev/null +++ b/fiscguy/migrations/0003_rename_payment_method_receipt_payment_terms.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2026-03-12 15:21 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("fiscguy", "0002_remove_receipt_payment_terms_receipt_payment_method"), + ] + + operations = [ + migrations.RenameField( + model_name="receipt", + old_name="payment_method", + new_name="payment_terms", + ), + ] diff --git a/fiscguy/models.py b/fiscguy/models.py index 9e3074a..6d05205 100644 --- a/fiscguy/models.py +++ b/fiscguy/models.py @@ -194,7 +194,7 @@ class ReceiptType(models.TextChoices): blank=True, related_name="receipts", ) - payment_method = models.CharField( + payment_terms = models.CharField( max_length=20, choices=PaymentMethod.choices, default=PaymentMethod.CASH, diff --git a/fiscguy/serializers.py b/fiscguy/serializers.py index 442710e..72a1c1f 100644 --- a/fiscguy/serializers.py +++ b/fiscguy/serializers.py @@ -96,6 +96,7 @@ class ReceiptCreateSerializer(serializers.ModelSerializer): credit_note_reference = serializers.CharField(required=False, allow_blank=True) credit_note_reason = serializers.CharField(required=False, allow_blank=True) + payment_terms = serializers.ChoiceField(choices=Receipt.PaymentMethod.choices) class Meta: model = Receipt diff --git a/fiscguy/services/closing_day_service.py b/fiscguy/services/closing_day_service.py index 33af9da..8496da2 100644 --- a/fiscguy/services/closing_day_service.py +++ b/fiscguy/services/closing_day_service.py @@ -211,18 +211,23 @@ def build_credit_note_tax_by_tax(self) -> str: def build_balance_by_money_type(self) -> str: strings: List[str] = [] - for c in self.counters: - - if c.fiscal_counter_type.lower() != "balancebymoneytype": - continue - - if ( - c.fiscal_counter_type.lower() == "balancebymoneytype" - and c.fiscal_counter_value == 0 - ): - continue + counters = [ + c + for c in self.counters + if c.fiscal_counter_type.lower() == "balancebymoneytype" and c.fiscal_counter_value != 0 + ] + + # sorting: currency -> moneyType + counters = sorted( + counters, + key=lambda c: ( + c.fiscal_counter_currency.upper(), + (c.fiscal_counter_money_type or "").upper(), + ), + ) - base: str = c.fiscal_counter_type.lower() + c.fiscal_counter_currency + for c in counters: + base = c.fiscal_counter_type.lower() + c.fiscal_counter_currency strings.append( f"{base}{c.fiscal_counter_money_type}{self._money_value(c.fiscal_counter_value)}" @@ -257,6 +262,8 @@ def close_day(self) -> Tuple[str, Dict[str, Any]]: f"{balance_by_money}" ).upper() + logger.info(f"Closing string: {closing_string}") + signature = self.receipt_handler.crypto.generate_receipt_hash_and_signature(closing_string) payload_counters = ( diff --git a/fiscguy/tests/test_api.py b/fiscguy/tests/test_api.py index 569d3fd..eb47e65 100644 --- a/fiscguy/tests/test_api.py +++ b/fiscguy/tests/test_api.py @@ -292,7 +292,7 @@ def test_submit_receipt_success(self, mock_handler_class): "receipt_type": "fiscalinvoice", "currency": "USD", "total_amount": Decimal("45.00"), - "payment_terms": "cash", + "payment_terms": "Cash", "lines": [ { "product": "Test Product", @@ -372,7 +372,7 @@ def test_submit_receipt_with_multiple_tax_types(self, mock_handler_class): "receipt_type": "fiscalinvoice", "currency": "USD", "total_amount": Decimal("45.00"), - "payment_terms": "cash", + "payment_terms": "Cash", "lines": [ { "product": "Product 1", @@ -417,6 +417,108 @@ def test_submit_receipt_with_multiple_tax_types(self, mock_handler_class): self.assertEqual(receipt.lines.count(), 3) +class SubmitReceiptPaymentMethodTest(APILibraryTestSetup): + """Test submitting receipts with different payment methods.""" + + def setUp(self): + super().setUp() + self.fiscal_day = FiscalDay.objects.create( + day_no=1, + is_open=True, + receipt_counter=0, + ) + + def _build_receipt_payload(self, payment_method): + """Helper to create a standard receipt payload with a given payment method.""" + return { + "receipt_type": "fiscalinvoice", + "currency": "USD", + "total_amount": Decimal("100.00"), + "payment_terms": payment_method, + "lines": [ + { + "product": "Test Product", + "quantity": Decimal("1"), + "unit_price": Decimal("100.00"), + "line_total": Decimal("100.00"), + "tax_amount": Decimal("0"), + "tax_name": "Standard rated 15.5%", + } + ], + "buyer": { + "name": self.buyer.name, + "tin_number": self.buyer.tin_number, + "trade_name": self.buyer.trade_name, + "email": self.buyer.email, + "address": self.buyer.address, + "phonenumber": self.buyer.phonenumber, + }, + } + + @patch("fiscguy.api.ZIMRAReceiptHandler") + def test_submit_receipt_card_payment(self, mock_handler_class): + """Test submitting a receipt with payment method Card.""" + mock_handler = MagicMock() + mock_handler_class.return_value = mock_handler + mock_handler.generate_receipt_data.return_value = { + "receipt_string": "FISCALINVOICEUSD1...", + "receipt_data": {"receiptTotal": 100.0, "receiptGlobalNo": 1}, + } + mock_handler.crypto.generate_receipt_hash_and_signature.return_value = { + "hash": "test-hash", + "signature": "test-signature", + } + mock_handler.submit_receipt.return_value = {"receiptID": 200} + + payload = self._build_receipt_payload("Card") + result = api.submit_receipt(payload) + + self.assertIsNotNone(result) + self.assertTrue(Receipt.objects.filter(payment_terms="Card").exists()) + + @patch("fiscguy.api.ZIMRAReceiptHandler") + def test_submit_receipt_mobile_wallet_payment(self, mock_handler_class): + """Test submitting a receipt with payment method Mobile Wallet.""" + mock_handler = MagicMock() + mock_handler_class.return_value = mock_handler + mock_handler.generate_receipt_data.return_value = { + "receipt_string": "FISCALINVOICEUSD2...", + "receipt_data": {"receiptTotal": 100.0, "receiptGlobalNo": 2}, + } + mock_handler.crypto.generate_receipt_hash_and_signature.return_value = { + "hash": "test-hash", + "signature": "test-signature", + } + mock_handler.submit_receipt.return_value = {"receiptID": 201} + + payload = self._build_receipt_payload("MobileWallet") + result = api.submit_receipt(payload) + + self.assertIsNotNone(result) + self.assertTrue(Receipt.objects.filter(payment_terms="MobileWallet").exists()) + + @patch("fiscguy.api.ZIMRAReceiptHandler") + def test_submit_receipt_bank_transfer_payment(self, mock_handler_class): + """Test submitting a receipt with payment method Bank Transfer.""" + mock_handler = MagicMock() + mock_handler_class.return_value = mock_handler + mock_handler.generate_receipt_data.return_value = { + "receipt_string": "FISCALINVOICEUSD3...", + "receipt_data": {"receiptTotal": 100.0, "receiptGlobalNo": 3}, + } + mock_handler.crypto.generate_receipt_hash_and_signature.return_value = { + "hash": "test-hash", + "signature": "test-signature", + } + mock_handler.submit_receipt.return_value = {"receiptID": 202} + + payload = self._build_receipt_payload("BankTransfer") + result = api.submit_receipt(payload) + + self.assertIsNotNone(result) + self.assertTrue(Receipt.objects.filter(payment_terms="BankTransfer").exists()) + + class GetConfigurationTest(APILibraryTestSetup): """Test the get_configuration() function.""" diff --git a/fiscguy/zimra_receipt_handler.py b/fiscguy/zimra_receipt_handler.py index 7e53bbc..2159b4a 100644 --- a/fiscguy/zimra_receipt_handler.py +++ b/fiscguy/zimra_receipt_handler.py @@ -13,6 +13,7 @@ import qrcode from django.core.files.base import ContentFile +from django.db import transaction from loguru import logger from fiscguy.models import Certs, Device @@ -363,9 +364,6 @@ def _update_fiscal_counters(self, receipt: dict, receipt_data: dict) -> None: fiscal_counter_currency=receipt.currency.lower(), fiscal_counter_tax_id=tax_id, fiscal_counter_tax_percent=tax_percent, - fiscal_counter_money_type=receipt_data["receiptPayments"][0][ - "moneyTypeCode" - ], fiscal_day=fiscal_day, defaults={ "fiscal_counter_value": sales_amount_with_tax, @@ -416,8 +414,6 @@ def _update_fiscal_counters(self, receipt: dict, receipt_data: dict) -> None: ) fiscal_sale_counter_obj.save() - logger.info(f"taxes: {receipt_data['receiptTaxes'][0]['taxAmount']}") - # CreditNoteTaxByTax if tax_percent and tax_name != "exempt" and tax_name != "zero rated 0%": fiscal_counter_obj, _stbt = FiscalCounter.objects.get_or_create( @@ -445,6 +441,7 @@ def _update_fiscal_counters(self, receipt: dict, receipt_data: dict) -> None: fiscal_counter_bal_obj, created_bal = FiscalCounter.objects.get_or_create( fiscal_counter_type="Balancebymoneytype", fiscal_counter_currency=receipt.currency.lower(), + fiscal_counter_money_type=receipt.payment_terms, fiscal_day=fiscal_day, defaults={ "fiscal_counter_tax_percent": None, From 7589fdcc2f92dd8b27c0f6153786f171bfb6395c Mon Sep 17 00:00:00 2001 From: casy Date: Fri, 13 Mar 2026 16:25:02 +0200 Subject: [PATCH 2/3] fix: ident --- .pre-commit-config.yaml | 7 ------- fiscguy/models.py | 22 +++++++++++----------- fiscguy/services/closing_day_service.py | 18 ++++++++++++------ 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 775d728..51a9100 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,11 +16,4 @@ repos: language: system types: [python] - - repo: local - hooks: - - id: pytest - name: pytest (run tests) - entry: pytest -v --maxfail=1 --disable-warnings - language: system - types: [python] diff --git a/fiscguy/models.py b/fiscguy/models.py index 16e4ba0..07d33c7 100644 --- a/fiscguy/models.py +++ b/fiscguy/models.py @@ -1,4 +1,4 @@ -from django.db import models, transaction +from django.db import models class Device(models.Model): @@ -152,15 +152,15 @@ class Receipt(models.Model): """ Receiipt model """ - - class PaymentMethod(models.TextChoices): - CASH = "Cash", "Cash" - CARD = "Card", "Card" - MOBILE_WALLET = "MobileWallet", "Mobile Wallet" - BANK_TRANSFER = "BankTransfer", "Bank Transfer" - COUPON = "Coupon", "Coupon" - CREDIT = "Credit", "Credit" - OTHER = "Other", "Other" + + class PaymentMethod(models.TextChoices): + CASH = "Cash", "Cash" + CARD = "Card", "Card" + MOBILE_WALLET = "MobileWallet", "Mobile Wallet" + BANK_TRANSFER = "BankTransfer", "Bank Transfer" + COUPON = "Coupon", "Coupon" + CREDIT = "Credit", "Credit" + OTHER = "Other", "Other" class ReceiptType(models.TextChoices): FISCAL_INVOICE = "fiscalinvoice", "Fiscal Invoice" @@ -199,7 +199,7 @@ class ReceiptType(models.TextChoices): choices=PaymentMethod.choices, default=PaymentMethod.CASH, ) - + submitted = models.BooleanField(default=False, null=True) is_credit_note = models.BooleanField(default=False, null=True) credit_note_reason = models.CharField(max_length=255, null=True, blank=True) diff --git a/fiscguy/services/closing_day_service.py b/fiscguy/services/closing_day_service.py index 8496da2..3373c83 100644 --- a/fiscguy/services/closing_day_service.py +++ b/fiscguy/services/closing_day_service.py @@ -211,19 +211,25 @@ def build_credit_note_tax_by_tax(self) -> str: def build_balance_by_money_type(self) -> str: strings: List[str] = [] + payment_order = { + "CASH": 1, + "CARD": 2, + "MOBILEWALLET": 3, + "BANKTRANSFER": 4, + "COUPON": 5, + "CREDIT": 6, + "OTHER": 7, + } + counters = [ c for c in self.counters if c.fiscal_counter_type.lower() == "balancebymoneytype" and c.fiscal_counter_value != 0 ] - # sorting: currency -> moneyType counters = sorted( counters, - key=lambda c: ( - c.fiscal_counter_currency.upper(), - (c.fiscal_counter_money_type or "").upper(), - ), + key=lambda c: payment_order.get((c.fiscal_counter_money_type or "").upper(), 999), ) for c in counters: @@ -277,7 +283,7 @@ def close_day(self) -> Tuple[str, Dict[str, Any]]: payload: Dict[str, Any] = { "deviceID": self.device.device_id, "fiscalDayNo": self.fiscal_day.day_no, - "fiscalDayDate": self._today(), + "fiscalDayDate": self.fiscal_day.created_at.strftime("%Y-%m-%d"), "fiscalDayCounters": payload_counters, "fiscalDayDeviceSignature": signature, "receiptCounter": self.fiscal_day.receipt_counter, From 834b1e0aa34e2d104c464fc5cc2bdd0dd125c94e Mon Sep 17 00:00:00 2001 From: casy Date: Fri, 13 Mar 2026 16:27:58 +0200 Subject: [PATCH 3/3] fix: blackk --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 51a9100..72cb90c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,6 +14,6 @@ repos: - id: black name: black (python code formatter) language: system - types: [python] + types: [python]