fiscguy 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fiscguy/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """
2
+ ZIMRA Fiscal Device Library
3
+
4
+ Public API for fiscal device operations. Import functions directly:
5
+
6
+ from fiscguy import open_day, close_day, submit_receipt, get_status, get_taxes, get_configuration
7
+
8
+ Each function handles initialization and error handling transparently.
9
+ """
10
+
11
+ default_app_config = "fiscguy.apps.FiscguyConfig"
12
+
13
+ def __getattr__(name):
14
+ """Lazy-load API functions on first access."""
15
+ if name in (
16
+ "open_day",
17
+ "close_day",
18
+ "get_status",
19
+ "submit_receipt",
20
+ "get_configuration",
21
+ "get_taxes"
22
+ ):
23
+ from fiscguy import api
24
+ return getattr(api, name)
25
+ raise AttributeError(f"module 'fiscguy' has no attribute '{name}'")
26
+
27
+
28
+ __all__ = [
29
+ "open_day",
30
+ "close_day",
31
+ "get_status",
32
+ "submit_receipt",
33
+ "get_configuration",
34
+ "get_taxes",
35
+ ]
fiscguy/admin.py ADDED
@@ -0,0 +1,19 @@
1
+ from django.contrib import admin
2
+
3
+ from fiscguy.models import (
4
+ Certs,
5
+ Configuration,
6
+ Device,
7
+ FiscalCounter,
8
+ FiscalDay,
9
+ Receipt,
10
+ Taxes,
11
+ )
12
+
13
+ admin.site.register(Certs)
14
+ admin.site.register(Configuration)
15
+ admin.site.register(Taxes)
16
+ admin.site.register(Device)
17
+ admin.site.register(FiscalDay)
18
+ admin.site.register(Receipt)
19
+ admin.site.register(FiscalCounter)
fiscguy/api.py ADDED
@@ -0,0 +1,235 @@
1
+ """
2
+ ZIMRA Fiscal Device Public API.
3
+
4
+ Provides high-level functions for fiscal operations:
5
+ - open_day: Open a new fiscal day
6
+ - close_day: Close the open fiscal day
7
+ - get_status: Get device and fiscal status
8
+ - submit_receipt: Create and submit a receipt to ZIMRA
9
+ - get_configuration: Fetch device configuration
10
+ - get_taxes: Fetch available tax types
11
+
12
+ This module encapsulates business logic previously in views,
13
+ providing a clean library interface for both API and programmatic use.
14
+ """
15
+
16
+ from typing import Dict, Any
17
+ from loguru import logger
18
+
19
+ from fiscguy.models import Device, FiscalDay, Taxes
20
+ from fiscguy.services.closing_day_service import ClosingDayService
21
+ from fiscguy.services.receipt_service import ReceiptService
22
+ from fiscguy.zimra_base import ZIMRAClient
23
+ from fiscguy.zimra_receipt_handler import ZIMRAReceiptHandler
24
+
25
+
26
+ # Module-level instances
27
+ _device = None
28
+ _client = None
29
+ _receipt_handler = None
30
+
31
+
32
+ def _get_device() -> Device:
33
+ """Get or cache the first device. Raises if none exists."""
34
+ global _device
35
+ if _device is None:
36
+ _device = Device.objects.first()
37
+ if not _device:
38
+ raise RuntimeError("No Device found. Please run init_device management command.")
39
+ return _device
40
+
41
+
42
+ def _get_client() -> ZIMRAClient:
43
+ """Get or cache the ZIMRA client. Lazy initialization."""
44
+ global _client
45
+ if _client is None:
46
+ device = _get_device()
47
+ logger.info(f"Initializing ZIMRA client for device {device}")
48
+ _client = ZIMRAClient(device)
49
+ return _client
50
+
51
+
52
+ def _get_receipt_handler() -> ZIMRAReceiptHandler:
53
+ """Get or cache the receipt handler. Lazy initialization."""
54
+ global _receipt_handler
55
+ if _receipt_handler is None:
56
+ _receipt_handler = ZIMRAReceiptHandler()
57
+ return _receipt_handler
58
+
59
+
60
+ def get_status() -> Dict[str, Any]:
61
+ """
62
+ Get the current device and fiscal day status.
63
+
64
+ Returns:
65
+ dict: Status response from ZIMRA FDMS.
66
+
67
+ Raises:
68
+ RuntimeError: If device not found or FDMS request fails.
69
+ """
70
+ logger.info("Fetching device status")
71
+ client = _get_client()
72
+ return client.get_status()
73
+
74
+
75
+ def open_day() -> Dict[str, Any]:
76
+ """
77
+ Open a new fiscal day.
78
+
79
+ Creates a FiscalDay record and calls ZIMRA to open the day.
80
+ Returns early if a day is already open.
81
+
82
+ Returns:
83
+ dict: Response from ZIMRA FDMS or local message if already open.
84
+
85
+ Raises:
86
+ RuntimeError: If device not found or FDMS request fails.
87
+ """
88
+ logger.info("Opening fiscal day")
89
+ client = _get_client()
90
+ return client.open_day()
91
+
92
+
93
+ def close_day() -> Dict[str, Any]:
94
+ """
95
+ Close the open fiscal day.
96
+
97
+ Collects fiscal counters, builds closing string, signs it, and submits
98
+ to ZIMRA. Updates the fiscal day status and returns the status payload.
99
+
100
+ Returns:
101
+ dict: Final device/fiscal status from ZIMRA FDMS.
102
+
103
+ Raises:
104
+ RuntimeError: If no open fiscal day or device not found.
105
+ Exception: If FDMS request fails.
106
+ """
107
+ logger.info("Closing fiscal day")
108
+ device = _get_device()
109
+ receipt_handler = _get_receipt_handler()
110
+ client = _get_client()
111
+
112
+ # Get open fiscal day
113
+ fiscal_day = FiscalDay.objects.filter(is_open=True).first()
114
+ if not fiscal_day:
115
+ return {"error": "No open fiscal day to close"}
116
+
117
+ # Collect counters and build closing payload
118
+ fiscal_counters = fiscal_day.counters.all()
119
+ tax_map = {t.tax_id: t.name for t in Taxes.objects.all()}
120
+
121
+ logger.info(f"Fiscal counters for day {fiscal_day.day_no}: {list(fiscal_counters)}")
122
+
123
+ service = ClosingDayService(
124
+ device=device,
125
+ fiscal_day=fiscal_day,
126
+ fiscal_counters=fiscal_counters,
127
+ tax_map=tax_map,
128
+ receipt_handler=receipt_handler,
129
+ )
130
+
131
+ closing_string, payload = service.close_day()
132
+
133
+ logger.info(f"Closing fiscal day string: {closing_string}")
134
+ logger.info(f"Closing payload: {payload}")
135
+
136
+ # Submit to ZIMRA and fetch final status
137
+ client.close_day(payload)
138
+ status_payload = client.get_status()
139
+
140
+ return status_payload
141
+
142
+
143
+ def submit_receipt(receipt_data: Dict[str, Any]) -> Dict[str, Any]:
144
+ """
145
+ Create and submit a receipt to ZIMRA.
146
+
147
+ Parses the receipt payload, creates Receipt and ReceiptLine records,
148
+ generates receipt data (signature/hash), and submits to ZIMRA.
149
+
150
+ Args:
151
+ receipt_data (dict): Receipt payload with structure:
152
+ {
153
+ "receipt_type": str,
154
+ "currency": str,
155
+ "total_amount": float,
156
+ "lines": [
157
+ {
158
+ "product": str,
159
+ "quantity": float,
160
+ "unit_price": float,
161
+ "line_total": float,
162
+ "tax_amount": float, # optional, can be calculated from tax percent if tax_name provided
163
+ "tax_name": str,
164
+ },
165
+ ...
166
+ ],
167
+ ...
168
+ }
169
+
170
+ Returns:
171
+ dict: Serialized receipt with all fields including ID and lines.
172
+
173
+ Raises:
174
+ ValidationError: If receipt_data is invalid (missing required fields, invalid tax).
175
+ Exception: If FDMS submission fails.
176
+ """
177
+ logger.info(f"Submitting receipt: {receipt_data}")
178
+ receipt_handler = _get_receipt_handler()
179
+
180
+ service = ReceiptService(receipt_handler=receipt_handler)
181
+ receipt, submission_res = service.create_and_submit_receipt(receipt_data)
182
+
183
+ logger.info(f"Receipt submitted to ZIMRA: {submission_res}")
184
+ return receipt
185
+
186
+
187
+ def get_configuration() -> Dict[str, Any]:
188
+ """
189
+ Get the stored device configuration.
190
+
191
+ Returns:
192
+ dict: Configuration fields (tax_payer_name, tin_number, vat_number, etc.)
193
+ or empty dict if no configuration exists.
194
+ """
195
+ from fiscguy.models import Configuration
196
+ from fiscguy.serializers import ConfigurationSerializer
197
+
198
+ logger.info("Fetching device configuration")
199
+ config = Configuration.objects.first()
200
+ if not config:
201
+ logger.warning("No configuration found")
202
+ return {}
203
+ return ConfigurationSerializer(config).data
204
+
205
+
206
+ def get_taxes() -> list:
207
+ """
208
+ Get all available tax types.
209
+
210
+ Returns:
211
+ list: Array of tax objects with fields:
212
+ {
213
+ "id": int,
214
+ "code": str,
215
+ "name": str,
216
+ "tax_id": int,
217
+ "percent": float
218
+ }
219
+ """
220
+ from fiscguy.serializers import TaxSerializer
221
+
222
+ logger.info("Fetching taxes")
223
+ taxes = Taxes.objects.all()
224
+ return TaxSerializer(taxes, many=True).data
225
+
226
+
227
+ # module-level shortcuts
228
+ __all__ = [
229
+ "open_day",
230
+ "close_day",
231
+ "get_status",
232
+ "submit_receipt",
233
+ "get_configuration",
234
+ "get_taxes",
235
+ ]
fiscguy/apps.py ADDED
@@ -0,0 +1,5 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class FiscguyConfig(AppConfig):
5
+ name = "fiscguy"
File without changes
File without changes
@@ -0,0 +1,354 @@
1
+ import json
2
+ import shutil
3
+ import tempfile
4
+ import threading
5
+ from pathlib import Path
6
+
7
+ import requests
8
+ from django.core.management.base import BaseCommand
9
+ from django.db import transaction
10
+ from loguru import logger
11
+
12
+ from fiscguy.models import Certs, Configuration, Device, Taxes
13
+ from fiscguy.zimra_crypto import ZIMRACrypto
14
+ from fiscguy.services.configuration_service import create_or_update_config
15
+
16
+ """
17
+ Management command to register a ZIMRA fiscal device and fetch its
18
+ configuration from the ZIMRA FDMS API.
19
+
20
+ Primary responsibilities:
21
+ - Interactively collect device registration details from the user.
22
+ - Generate CSR and register the device to obtain a signed certificate.
23
+ - Fetch device configuration using the signed certificate and persist
24
+ the taxpayer configuration and applicable taxes into local models.
25
+
26
+ This command intentionally writes/updates a single `Configuration` row
27
+ and replaces the `Taxes` table contents with the `applicableTaxes`
28
+ returned from ZIMRA. Database writes are wrapped in a transaction to
29
+ avoid partial updates.
30
+
31
+ Note: network calls to ZIMRA use client certificates stored in the
32
+ `Certs` model. Temporary files are created for the requests library
33
+ and removed afterwards.
34
+ """
35
+
36
+
37
+ crypto = ZIMRACrypto()
38
+
39
+
40
+ class Command(BaseCommand):
41
+ """Django management command for device registration.
42
+
43
+ Usage (interactive): run `python manage.py init_device` and follow
44
+ the prompts. The command will:
45
+ - create/update a `Device` record
46
+ - generate a CSR and register the device to obtain a signed cert
47
+ - fetch and persist configuration and taxes from ZIMRA's FDMS
48
+
49
+ The class keeps operations small and logs failures rather than
50
+ raising unhandled exceptions so it can be used in ad-hoc admin
51
+ workflows.
52
+ """
53
+
54
+ help = "Interactive registration of a new ZIMRA device"
55
+
56
+ def handle(self, *args, **options):
57
+ print("\n" + "*" * 75)
58
+ print("*" + " " * 73 + "*")
59
+ print("* **** ** **** **** **** ** ** ** ** ")
60
+ print("* ** ** ** ** ** ** ** ** ** ")
61
+ print("* **** ** **** ** ** ** ** ** ***** ")
62
+ print("* ** ** ** ** ** ** ** ** ** ")
63
+ print("* ** ** **** **** **** **** ** ")
64
+ print("*" + " " * 73 + "*")
65
+ print("*" * 75)
66
+ print("\nDeveloped by Casper Moyo")
67
+ print("Version 1.0.0\n")
68
+ print(
69
+ "Welcome to device registration please input the following provided information as proveded by ZIMRA\n"
70
+ )
71
+
72
+ environment = input(
73
+ "Enter yes for production environment and no for test enviroment: "
74
+ ).strip()
75
+ org = input("Enter your organisation name: ").strip()
76
+ device_id = input("Enter your device ID: ").strip()
77
+ activation_key = input("Enter activation Key: ").strip()
78
+ model_version = input("Enter device model version eg v1: ").strip()
79
+ model_name = input("Enter device model name eg Server: ").strip()
80
+ device_sn = input("Enter device serial number: ").strip()
81
+
82
+ if not environment.lower() in ["yes", "no"]:
83
+ self.stdout.write(
84
+ self.style.ERROR("Please input environment between yes or no")
85
+ )
86
+ return
87
+
88
+ if (
89
+ not device_id
90
+ or not model_version
91
+ or not model_name
92
+ or not environment
93
+ or not device_sn
94
+ or not org
95
+ or not activation_key
96
+ ):
97
+ self.stdout.write(self.style.ERROR("All fields are required."))
98
+ return
99
+
100
+ device = Device.objects.first()
101
+ env = True if environment.lower() == "yes" else False
102
+
103
+ if not device:
104
+ device = Device.objects.create(
105
+ org_name=org,
106
+ activation_key=activation_key,
107
+ device_id=device_id,
108
+ device_model_name=model_name,
109
+ device_serial_number=device_sn,
110
+ device_model_version=model_version,
111
+ production=env,
112
+ )
113
+ print(
114
+ f"Device {device.device_id} created for {'production' if env else 'test'} environment."
115
+ )
116
+
117
+ else:
118
+ if device.production != env:
119
+ print("\n" + "!" * 75)
120
+ print("!" + " " * 73 + "!")
121
+ print("! WARNING: ENVIRONMENT SWITCH DETECTED" + " " * 33 + "!")
122
+ print("!" + " " * 73 + "!")
123
+ print("!" + " " * 73 + "!")
124
+ print("! Switching from", "PRODUCTION" if device.production else "TEST", "to", "PRODUCTION" if env else "TEST" + " " * (73 - 54) + "!")
125
+ print("!" + " " * 73 + "!")
126
+ print("! ALL TEST DATA WILL BE PERMANENTLY DELETED:" + " " * 28 + "!")
127
+ print("! - Fiscal Days" + " " * 57 + "!")
128
+ print("! - Fiscal Counters" + " " * 52 + "!")
129
+ print("! - Receipts & Receipt Lines" + " " * 40 + "!")
130
+ print("! - Device Configuration" + " " * 47 + "!")
131
+ print("! - Certificates" + " " * 55 + "!")
132
+ print("! - Device Record" + " " * 54 + "!")
133
+ print("!" + " " * 73 + "!")
134
+ print("!" * 75)
135
+
136
+ confirm = input(
137
+ "\nType 'YES' to confirm data deletion and switch environment, or press Enter to cancel: "
138
+ ).strip()
139
+
140
+ if confirm.upper() != "YES":
141
+ print("Environment switch cancelled. No data was deleted.")
142
+ return
143
+
144
+ print("\nDeleting all test data...")
145
+ self.delete_all_test_data()
146
+ print("✓ All test data has been deleted.\n")
147
+
148
+ device.org_name = org
149
+ device.activation_key = activation_key
150
+ device.device_id = device_id
151
+ device.device_model_name = model_name
152
+ device.device_serial_number = device_sn
153
+ device.device_model_version = model_version
154
+ device.production = env
155
+ device.save()
156
+ print(
157
+ f"Device {device.device_id} updated to {'production' if env else 'test'} environment."
158
+ )
159
+ else:
160
+ device.org_name = org
161
+ device.activation_key = activation_key
162
+ device.device_id = device_id
163
+ device.save()
164
+ print(f"Device {device.device_id} updated for current environment.")
165
+
166
+ cert_key, csr = crypto.generate_key_and_csr(device_sn, device_id, env)
167
+
168
+ # register the device and get signed certificate from ZIMRA
169
+ self.register_device(
170
+ device_id, activation_key, model_name, model_version, env, csr, device_sn
171
+ )
172
+
173
+ # get zimra configurations for the provided device
174
+ zimra_config = self.get_config(
175
+ device_id, model_name, model_version, device.production
176
+ )
177
+ print(zimra_config)
178
+
179
+ def delete_all_test_data(self) -> None:
180
+ """
181
+ Delete all test data when switching environments.
182
+
183
+ Deletes in order of dependencies:
184
+ - Fiscal Days
185
+ - Fiscal Counters
186
+ - Receipts & Receipt Lines
187
+ - Configuration
188
+ - Certificates
189
+ - Device
190
+ - Taxes
191
+
192
+ """
193
+ try:
194
+ from fiscguy.models import (
195
+ FiscalDay,
196
+ FiscalCounter,
197
+ Receipt,
198
+ ReceiptLine,
199
+ Configuration,
200
+ )
201
+
202
+ with transaction.atomic():
203
+ # Delete in order of dependencies (child tables first)
204
+ logger.info("Deleting receipt lines...")
205
+ count = ReceiptLine.objects.all().delete()[0]
206
+ self.stdout.write(self.style.SUCCESS(f" Deleted {count} receipt lines"))
207
+
208
+ logger.info("Deleting receipts...")
209
+ count = Receipt.objects.all().delete()[0]
210
+ self.stdout.write(self.style.SUCCESS(f" Deleted {count} receipts"))
211
+
212
+ logger.info("Deleting fiscal counters...")
213
+ count = FiscalCounter.objects.all().delete()[0]
214
+ self.stdout.write(self.style.SUCCESS(f" Deleted {count} fiscal counters"))
215
+
216
+ logger.info("Deleting fiscal days...")
217
+ count = FiscalDay.objects.all().delete()[0]
218
+ self.stdout.write(self.style.SUCCESS(f" Deleted {count} fiscal days"))
219
+
220
+ logger.info("Deleting configuration...")
221
+ count = Configuration.objects.all().delete()[0]
222
+ self.stdout.write(self.style.SUCCESS(f" Deleted {count} configuration records"))
223
+
224
+ logger.info("Deleting certificates...")
225
+ count = Certs.objects.all().delete()[0]
226
+ self.stdout.write(self.style.SUCCESS(f" Deleted {count} certificates"))
227
+
228
+ logger.info("Deleting taxes...")
229
+ count = Taxes.objects.all().delete()[0]
230
+ self.stdout.write(self.style.SUCCESS(f" Deleted {count} tax records"))
231
+
232
+ logger.info("Deleting device...")
233
+ count = Device.objects.all().delete()[0]
234
+ self.stdout.write(self.style.SUCCESS(f" Deleted {count} devices"))
235
+
236
+ logger.info("All test data successfully deleted")
237
+ except Exception as e:
238
+ logger.exception(f"Error deleting test data: {e}")
239
+ self.stdout.write(self.style.ERROR(f"ERROR: Failed to delete test data: {e}"))
240
+ raise
241
+
242
+ def get_config(
243
+ self, device_id: str, model_name: str, model_version: str, env: bool
244
+ ) -> dict:
245
+ """Fetch device configuration from ZIMRA and persist locally.
246
+
247
+ Args:
248
+ device_id (str): Device identifier supplied by ZIMRA.
249
+ model_name (str): Device model name header required by FDMS.
250
+ model_version (str): Device model version header.
251
+ env (bool): True for production FDMS endpoint, False for test.
252
+
253
+ Returns:
254
+ dict | None: Parsed JSON response from FDMS on success, or
255
+ None when the HTTP request fails.
256
+
257
+ Side effects:
258
+ - Creates/updates a single `Configuration` row.
259
+ - Replaces all rows in `Taxes` with the `applicableTaxes`
260
+ returned by the FDMS response.
261
+ """
262
+
263
+ logger.info(f"Fetching device: {device_id} configurations")
264
+
265
+ url = (
266
+ f"https://fdmsapi.zimra.co.zw/Device/v1/{device_id}"
267
+ if env
268
+ else f"https://fdmsapitest.zimra.co.zw/Device/v1/{device_id}"
269
+ )
270
+
271
+ headers = {
272
+ "Content-Type": "application/json",
273
+ "deviceModelName": model_name,
274
+ "deviceModelVersion": model_version,
275
+ }
276
+
277
+ temp_dir = Path(tempfile.mkdtemp(prefix="zimra_fdms_"))
278
+ cert_path = temp_dir / "client_cert.pem"
279
+ key_path = temp_dir / "client_key.pem"
280
+
281
+ from fiscguy.models import Certs
282
+
283
+ cert = Certs.objects.first()
284
+ cert_path.write_text(cert.certificate)
285
+ key_path.write_text(cert.certificate_key)
286
+
287
+ try:
288
+ response = requests.get(
289
+ f"{url}/getConfig",
290
+ headers=headers,
291
+ cert=(str(cert_path), str(key_path)),
292
+ timeout=30,
293
+ )
294
+ response.raise_for_status()
295
+ res = response.json()
296
+
297
+ create_or_update_config(res)
298
+ logger.info(f"Configuration for device {device_id} updated successfully.")
299
+ return res
300
+ except requests.RequestException as e:
301
+ logger.error(f"Error fetching config: {e}")
302
+ return None
303
+ finally:
304
+ cert_path.unlink(missing_ok=True)
305
+ key_path.unlink(missing_ok=True)
306
+ temp_dir.rmdir()
307
+
308
+ def register_device(
309
+ self, device_id, activation_key, model_name, model_version, env, csr, device_sn
310
+ ):
311
+ logger.info(f"Registering device: {device_id}")
312
+ url = (
313
+ f"https://fdmsapi.zimra.co.zw/Public/v1/{device_id}"
314
+ if env
315
+ else f"https://fdmsapitest.zimra.co.zw/Public/v1/{device_id}"
316
+ )
317
+ print(csr)
318
+ csr = csr.replace("\n", "")
319
+ print(csr)
320
+
321
+ payload = {
322
+ "activationKey": activation_key,
323
+ "deviceSerial": device_sn,
324
+ "certificateRequest": csr,
325
+ }
326
+
327
+ headers = {
328
+ "Content-Type": "application/json",
329
+ "deviceModelName": model_name,
330
+ "deviceModelVersion": model_version,
331
+ }
332
+
333
+ try:
334
+ response = requests.post(
335
+ f"{url}/RegisterDevice", json=payload, headers=headers
336
+ )
337
+ response.raise_for_status()
338
+
339
+ logger.info(response.json())
340
+
341
+ signed_certificate = response.json().get("certificate")
342
+
343
+ if signed_certificate:
344
+ from fiscguy.models import Certs
345
+
346
+ cert = Certs.objects.first()
347
+ cert.certificate = signed_certificate
348
+ cert.save()
349
+
350
+ logger.info(f"Device {device_id} registered successfully.")
351
+ return signed_certificate
352
+ except Exception as e:
353
+ logger.error(f"Device registration failed: {e}")
354
+ return