Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,6 @@ repos:
- id: black
name: black (python code formatter)
language: system
types: [python]
types: [python]

- repo: local
hooks:
- id: pytest
name: pytest (run tests)
entry: pytest -v --maxfail=1 --disable-warnings
language: system
types: [python]

8 changes: 8 additions & 0 deletions fiscguy/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions fiscguy/management/commands/init_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: "
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
Original file line number Diff line number Diff line change
@@ -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",
),
]
18 changes: 16 additions & 2 deletions fiscguy/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from django.db import models, transaction
from django.db import models


class Device(models.Model):
Expand Down Expand Up @@ -153,6 +153,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 ReceiptType(models.TextChoices):
FISCAL_INVOICE = "fiscalinvoice", "Fiscal Invoice"
CREDIT_NOTE = "creditnote", "Creditnote"
Expand Down Expand Up @@ -185,7 +194,12 @@ class ReceiptType(models.TextChoices):
blank=True,
related_name="receipts",
)
payment_terms = models.CharField(max_length=200)
payment_terms = models.CharField(
max_length=20,
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)
Expand Down
1 change: 1 addition & 0 deletions fiscguy/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 23 additions & 10 deletions fiscguy/services/closing_day_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,18 +211,29 @@ 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:
payment_order = {
"CASH": 1,
"CARD": 2,
"MOBILEWALLET": 3,
"BANKTRANSFER": 4,
"COUPON": 5,
"CREDIT": 6,
"OTHER": 7,
}

if c.fiscal_counter_type.lower() != "balancebymoneytype":
continue
counters = [
c
for c in self.counters
if c.fiscal_counter_type.lower() == "balancebymoneytype" and c.fiscal_counter_value != 0
]

if (
c.fiscal_counter_type.lower() == "balancebymoneytype"
and c.fiscal_counter_value == 0
):
continue
counters = sorted(
counters,
key=lambda c: payment_order.get((c.fiscal_counter_money_type or "").upper(), 999),
)

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)}"
Expand Down Expand Up @@ -257,6 +268,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 = (
Expand All @@ -270,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,
Expand Down
106 changes: 104 additions & 2 deletions fiscguy/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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."""

Expand Down
7 changes: 2 additions & 5 deletions fiscguy/zimra_receipt_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
Loading