FiscGuy
A Django library that handles the full fiscal device lifecycle — device registration, certificate management, fiscal day operations, and receipt submission to the ZIMRA Fiscal Device Management System (FDMS).
What it does
FiscGuy plugs into your existing Django project and exposes a REST API that bridges your application to ZIMRA FDMS. It handles:
- Device registration — RSA key generation, CSR creation, and ZIMRA certificate issuance
- Fiscal day management — open and close fiscal days, tracking counters per day
- Receipt submission — create fiscal invoices, credit notes, and debit notes; hash, sign, and submit to FDMS
- QR code generation — auto-generated and stored on every submitted receipt
- Certificate renewal — re-issue expired certificates without re-registering the device
- Tax & configuration sync — pull the latest taxpayer config and applicable taxes from FDMS
Requirements
| Requirement | Version |
|---|---|
| Python | 3.11, 3.12, or 3.13 |
| Django | 4.2+ |
| Django REST Framework | 3.14+ |
Quick start
Install
pip install fiscguy
Add to INSTALLED_APPS and run migrations
# settings.py
INSTALLED_APPS = ["rest_framework", "fiscguy", ...]
python manage.py migrate
Register your device
python manage.py init_device
Open a fiscal day and submit a receipt
POST /fiscguy/open-day/
POST /fiscguy/receipts/
FDMS environments
| Environment | URL |
|---|---|
| Testing | https://fdmsapitest.zimra.co.zw |
| Production | https://fdmsapi.zimra.co.zw |
Installation & Setup
Get FiscGuy running in an existing Django project in four steps.
Install
pip install fiscguy
Or install from source:
git clone https://github.com/digitaltouchcode/fisc.git
cd fisc
pip install -e ".[dev]"
Django setup
1 · INSTALLED_APPS
# settings.py
INSTALLED_APPS = [
"django.contrib.contenttypes",
"django.contrib.auth",
"rest_framework",
"fiscguy",
# ... your other apps
]
2 · Migrations
python manage.py migrate
3 · URL routing
# your_project/urls.py
from django.urls import path, include
urlpatterns = [
path("fiscguy/", include("fiscguy.urls")),
]
All FiscGuy endpoints will be available under /fiscguy/.
4 · Media files (QR codes)
# settings.py
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
# urls.py (development only)
from django.conf import settings
from django.conf.urls.static import static
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
Device initialisation
Run this once per device before using any other feature. This registers the device with ZIMRA, generates an RSA key pair, and issues a certificate.
python manage.py init_device
Prompts
| Prompt | Example | Description |
|---|---|---|
| Environment | yes / no | yes = production, no = test |
| Organisation name | Acme Ltd | Registered company name |
| Device ID | 78412 | Provided by ZIMRA |
| Activation key | ABC-123-XYZ | Provided by ZIMRA |
| Device model name | Server | Provided by ZIMRA |
| Device model version | 1.0 | Provided by ZIMRA |
| Device serial number | SN0001 | Provided by ZIMRA |
init_device with a different environment will delete all existing data (receipts, fiscal days, counters, configuration, certificates, taxes). Type YES to confirm.Verify setup
from fiscguy.models import Device, Configuration, Taxes
device = Device.objects.first()
print(device) # "Acme Ltd - 78412"
config = Configuration.objects.first()
print(config.tax_payer_name)
print(Taxes.objects.all())
Development dependencies
pip install -e ".[dev]"
Includes: pytest, pytest-django, pytest-cov, black, isort, flake8, pylint, mypy, django-stubs.
pre-commit install # runs black, isort, flake8 on every commit
Receipt Types
FiscGuy supports three receipt types as defined by the ZIMRA Fiscal Device Gateway API: fiscal invoices, credit notes, and debit notes.
Fiscal Invoice
A standard sale receipt. The most common receipt type.
POST /fiscguy/receipts/
{
"receipt_type": "fiscalinvoice",
"currency": "USD",
"total_amount": "115.00",
"payment_terms": "Cash",
"lines": [
{
"product": "Product Name",
"quantity": "1",
"unit_price": "115.00",
"line_total": "115.00",
"tax_amount": "15.00",
"tax_name": "Standard rated 15.5"
}
]
}
Counter impact
| Counter | Change |
|---|---|
SaleByTax | + salesAmountWithTax per tax group |
SaleTaxByTax | + taxAmount per tax group (non-exempt, non-zero) |
BalanceByMoneyType | + paymentAmount |
Credit Note
A return or reversal against a previously issued fiscal invoice. All amounts must be negative.
POST /fiscguy/receipts/
{
"receipt_type": "creditnote",
"currency": "USD",
"total_amount": "-115.00",
"payment_terms": "Cash",
"credit_note_reason": "Customer returned goods — defective",
"credit_note_reference": "R-00000142",
"lines": [
{
"product": "Product Name",
"quantity": "1",
"unit_price": "-115.00",
"line_total": "-115.00",
"tax_amount": "-15.00",
"tax_name": "Standard rated 15.5"
}
]
}
credit_note_reason and credit_note_reference are mandatory. Original receipt must exist in FDMS, have been issued within the last 12 months (RCPT033), and the credit amount must not exceed the original amount net of prior credits (RCPT035).
Debit Note
An upward adjustment against a previously issued fiscal invoice — e.g. additional delivery charges.
POST /fiscguy/receipts/
{
"receipt_type": "debitnote",
"currency": "USD",
"total_amount": "23.00",
"payment_terms": "Card",
"credit_note_reason": "Additional delivery charge",
"credit_note_reference": "R-00000142",
"lines": [...]
}
Payment methods
| Value | Description |
|---|---|
Cash | Physical cash |
Card | Credit or debit card |
MobileWallet | Mobile money (EcoCash, etc.) |
BankTransfer | Direct bank transfer |
Coupon | Voucher or coupon |
Credit | Credit account |
Other | Any other method |
Tax types
Taxes are synced from FDMS on every open_day() and stored in the Taxes model. Pass tax_name exactly as it appears there.
| Tax ID | Name | Percent |
|---|---|---|
| 1 | Exempt | 0% |
| 2 | Zero Rated | 0% |
| 3+ | Standard Rated | 15.5% |
Closing a Fiscal Day
At the end of each trading day the fiscal day must be closed by submitting a signed summary of all fiscal counters to ZIMRA FDMS.
Quick close
POST /fiscguy/close-day/
What happens during close
ClosingDayService.close_day() performs these steps in order:
- Build counter strings — each counter is serialised into the ZIMRA closing string format
- Assemble the closing string —
deviceID + fiscalDayNo + fiscalDayDate + counters - Hash and sign — SHA-256 hash of the string, signed with the device RSA private key
- Build payload — closing string, signature, fiscal day counters, receipt counter
- Submit to FDMS —
POST /CloseDay - Poll for status — waits 10 seconds then calls
GET /getStatus - Update database — marks
FiscalDay.is_open = Falseon success
Closing string spec
Fields concatenated in this exact order, all text uppercase, no separators:
| Order | Field | Format |
|---|---|---|
| 1 | deviceID | Integer as-is |
| 2 | fiscalDayNo | Integer as-is |
| 3 | fiscalDayDate | YYYY-MM-DD — date the fiscal day was opened, not today |
| 4 | fiscalDayCounters | Concatenated counter string (see below) |
fiscalDayDate must be the date the day was opened, not today's date. Using today's date causes CountersMismatch if the day spans midnight.Counter string rules
| Rule | Detail |
|---|---|
| Uppercase | .upper() applied to the entire string |
| Amounts in cents | int(round(value × 100)) — preserves sign |
| Tax percent format | Always two decimal places: 15 → 15.00, 14.5 → 14.50 |
| Exempt tax | Empty string — nothing between currency and value |
| BalanceByMoneyType | Literal L between currency and money type, e.g. USDLCASH |
| Zero-value counters | Excluded entirely (spec section 4.11) |
| Sort order | Type enum ASC → currency ASC → taxID / moneyType ASC |
Example counter string
23265842026-03-30SALEBYTAXZWG0.005000SALEBYTAXZWG15.50134950SALETAXBYTAXZWG15.5018110BALANCEBYMONEYTYPEZWGLCARD69975BALANCEBYMONEYTYPEZWGLCASH69975
Common close day errors
| Error | Root cause |
|---|---|
CountersMismatch | Wrong date, missing L, wrong tax percent format, unsorted counters, or zero counters included |
BadCertificateSignature | Certificate expired or wrong private key used |
FiscalDayCloseFailed | FDMS validation failed — check fiscalDayClosingErrorCode |
Fiscal day status values
| Status | Meaning |
|---|---|
FiscalDayOpened | Day is open, receipts can be submitted |
FiscalDayCloseInitiated | Close request submitted, processing |
FiscalDayClosed | Day closed successfully |
FiscalDayCloseFailed | Close attempt failed — day remains open, retry allowed |
Retrying a failed close
FDMS allows close retries when the fiscal day status is FiscalDayOpened or FiscalDayCloseFailed.
from fiscguy.exceptions import CloseDayError
try:
# POST /fiscguy/close-day/
close_day()
except CloseDayError as e:
print(f"Close failed: {e}")
# investigate, fix, then retry
close_day()
Fiscal Counters
Fiscal counters are running totals that accumulate throughout a fiscal day. At close of day they are submitted to ZIMRA as part of the closing payload and used to verify the hash signature.
Counter types
| Counter | Tracks | By Tax | By Currency | By Payment |
|---|---|---|---|---|
SaleByTax | Sales amount including tax | ✓ | ✓ | |
SaleTaxByTax | Tax portion of sales | ✓ | ✓ | |
CreditNoteByTax | Total credit note amounts | ✓ | ✓ | |
CreditNoteTaxByTax | Tax portion of credit notes | ✓ | ✓ | |
DebitNoteByTax | Total debit note amounts | ✓ | ✓ | |
DebitNoteTaxByTax | Tax portion of debit notes | ✓ | ✓ | |
BalanceByMoneyType | Total collected by payment method | ✓ | ✓ |
How counters are updated
Every time a receipt is submitted, counters update automatically. You never need to update them manually.
Fiscal invoice
SaleByTax += salesAmountWithTax (per tax group)
SaleTaxByTax += taxAmount (per tax group, non-exempt/non-zero)
BalanceByMoneyType += paymentAmount
Credit note (values are negative)
CreditNoteByTax += salesAmountWithTax (negative, per tax group)
CreditNoteTaxByTax += taxAmount (negative, non-exempt/non-zero)
BalanceByMoneyType += paymentAmount (negative)
Race condition prevention
Counter updates use Django F() expressions for atomic DB-level increments, preventing lost updates under concurrent receipt submission.
# FiscGuy uses atomic F() expressions — not in-memory increment
FiscalCounter.objects.filter(...).update(
fiscal_counter_value=F("fiscal_counter_value") + amount
)
Inspecting counters
from fiscguy.models import FiscalDay
fiscal_day = FiscalDay.objects.filter(is_open=True).first()
print(fiscal_day.counters.all().values(
"fiscal_counter_type",
"fiscal_counter_currency",
"fiscal_counter_value",
))
open_day() starts fresh from zero.Certificate Management
FiscGuy uses mutual TLS authentication with ZIMRA FDMS. The device must hold a valid certificate issued by ZIMRA to submit any signed request.
How certificates work
init_devicegenerates an RSA key pair and a Certificate Signing Request (CSR)- The CSR is sent to ZIMRA FDMS
RegisterDeviceendpoint - ZIMRA returns a signed certificate
- The certificate and private key are stored in the
Certsmodel - Every request to FDMS uses the certificate for mutual TLS authentication
Certificate storage
from fiscguy.models import Certs
cert = Certs.objects.first()
print(cert.certificate) # PEM-encoded certificate
print(cert.certificate_key) # PEM-encoded private key
print(cert.csr) # Original CSR
print(cert.production) # True = production, False = testing
Certificate renewal
When a certificate expires, all signed requests will fail with BadCertificateSignature.
Via REST endpoint
POST /fiscguy/issue-certificate/
{ "message": "Certificate issued successfully" }
Via Python
from fiscguy.services.certs_service import CertificateService
from fiscguy.models import Device
device = Device.objects.first()
CertificateService(device).issue_certificate()
Key generation
| Algorithm | Spec reference |
|---|---|
| RSA 2048 | Section 12.1.2 |
| ECC ECDSA secp256r1 (P-256) | Section 12.1.1 |
Security notes
Certs.certificate_key. Encryption at rest is planned for v0.1.7. Never expose this field via API or logs. The key never leaves the device — only the CSR is sent to ZIMRA.At runtime, ZIMRAClient writes the certificate and key to a temporary PEM file. The temp file is cleaned up when the client is closed (ZIMRAClient.close()).
API Reference
All endpoints are prefixed with /fiscguy/ based on your URL configuration. Base URL: https://your-host/fiscguy/
Fiscal Day
Opens a new fiscal day for the registered device. If a fiscal day is already open, returns it immediately. On open, taxpayer configuration is also synced from FDMS. No request body required.
{ "success": true, "fiscal_day_no": 42, "fdms_response": { ... } }
{ "success": true, "fiscal_day_no": 42, "message": "Fiscal day 42 already open" }
Closes the currently open fiscal day. Builds fiscal counters, signs the closing payload, and submits to FDMS.
{ "success": true, "fiscal_day_no": 42, "fdms_response": { ... } }
{ "error": "FDMS returned an error: ..." }
Receipts
Returns all receipts in reverse chronological order with cursor-based pagination.
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| cursor | string | — | Pagination cursor from a previous response |
| page_size | integer | 10 | Results per page (max 100) |
{
"next": "http://example.com/fiscguy/receipts/?cursor=cD0...",
"previous": null,
"results": [{ ...receipt object... }]
}
Creates a receipt, signs it cryptographically, generates a QR code, updates fiscal counters, and submits to ZIMRA FDMS — all in one atomic operation. If submission fails the receipt is not saved.
Request body
| Field | Type | Description |
|---|---|---|
| receipt_type* | string | fiscalinvoice, creditnote, or debitnote |
| total_amount* | decimal | Negative for credit notes |
| currency* | string | USD or ZWG |
| payment_terms* | string | Cash, Card, MobileWallet, BankTransfer, Coupon, Credit, Other |
| lines* | array | One or more line items |
| buyer | object | Full buyer object. Omit for anonymous sales. |
| credit_note_reference | string | Required for creditnote |
| credit_note_reason | string | Human-readable reason |
Returns the full receipt object including receipt_number, hash_value, signature, zimra_inv_id, and qr_code.
{ "error": "FDMS returned an error: ..." }
Buyers
[{
"id": 1,
"name": "Casy Holdings",
"tin_number": "2000123456",
"address": "123 Main St, Harare",
"trade_name": "Casy Retail",
"email": "accounts@casy.co.zw",
"phonenumber": "+263771234567"
}]
| Field | Type | Description |
|---|---|---|
| name* | string | Registered business name |
| tin_number* | string | 10-digit ZIMRA TIN number |
| address | string | Physical address |
| trade_name | string | Trading name / branch name |
| string | Contact email | |
| phonenumber | string | Contact phone number |
Also supports PUT for full update, PATCH for partial update, and DELETE to remove a buyer.
Configuration & Taxes
{
"tax_payer_name": "ACME Ltd",
"tin_number": "1234567890",
"vat_number": "V123456789",
"address": "10 Commerce Drive, Harare"
}
Pulls the latest configuration from ZIMRA FDMS and updates the local database. Also called automatically when opening a fiscal day.
{ "message": "Configuration Synced" }
[
{ "code": "A", "name": "Standard rated 15.5", "tax_id": 1, "percent": "15.00" },
{ "code": "B", "name": "Zero Rated", "tax_id": 2, "percent": "0.00" },
{ "code": "C", "name": "Exempt", "tax_id": 3, "percent": "0.00" }
]
Device Management
{ "lastFiscalDayNo": 41, "lastReceiptGlobalNo": 150, "fiscalDayStatus": "Closed" }
Confirms the device is online and communicating with FDMS correctly.
{ "success": true }
Renews the device certificate with ZIMRA FDMS. Use when the current certificate has expired.
{ "message": "Certificate issued successfully" }
{ "error": "Certificate renewal issuance failed." }
HTTP status codes
| Status | Meaning |
|---|---|
| 200 OK | Request succeeded |
| 201 Created | Receipt created and submitted |
| 400 Bad Request | Invalid input — fiscal day already open, etc. |
| 404 Not Found | No device registered |
| 422 Unprocessable Entity | ZIMRA rejected the request |
| 500 Internal Server Error | Unexpected server error |
Error Reference
All FiscGuy exceptions inherit from FiscalisationError. Import them from fiscguy.exceptions.
Exception hierarchy
Common exceptions
ReceiptSubmissionError
Raised when a receipt cannot be processed or submitted.
- No open fiscal day — call
open_day()first - Invalid receipt data (missing required fields, wrong types)
- FDMS rejected the receipt (validation errors)
- Credit note references a non-existent original receipt
CloseDayError
| Error code | Cause | Fix |
|---|---|---|
CountersMismatch | Closing string counters don't match FDMS | Check date, tax percent format, and sort order |
BadCertificateSignature | Certificate expired or wrong key | Renew certificate via POST /fiscguy/issue-certificate/ |
FiscalDayCloseFailed | FDMS validation failed | Check fiscalDayClosingErrorCode in logs |
FiscalDayError
A fiscal day is already open, FDMS rejected the open request, or the previous fiscal day was not closed.
ConfigurationError
init_device was not run, or FDMS was unreachable during configuration sync.
Logging
FiscGuy uses loguru for structured logging.
from loguru import logger
logger.add("fiscguy.log", level="INFO", rotation="1 day")
| Level | Event |
|---|---|
INFO | Receipt submitted, day opened/closed, client initialised |
WARNING | FDMS offline (receipt queued), global number mismatch |
ERROR | Receipt submission failed, close day failed |
EXCEPTION | Unexpected errors with full traceback |
Troubleshooting
| Error | Fix |
|---|---|
RuntimeError: No Device found | Run python manage.py init_device |
RuntimeError: ZIMRA configuration missing | POST /fiscguy/sync-config/ |
MalformedFraming: Unable to load PEM file | Re-run init_device — certificate corrupted |
No open fiscal day | POST /fiscguy/open-day/ |
| Certificate expired | POST /fiscguy/issue-certificate/ |
| Receipt submission fails (422) | Check day is open, amounts sign, credit note reference, TIN is 10 digits |
Architecture
Internal engineering reference for contributors and maintainers. Version 0.1.6 · April 2026 · Maintainer: Casper Moyo
System overview
Layer architecture
FiscGuy follows a strict four-layer architecture. Each layer has a single responsibility and communicates only with the layer directly below it.
Receipt processing pipeline
A POST /fiscguy/receipts/ call flows through these steps:
- Validation —
ReceiptCreateSerializer.is_valid()checks types, amounts, TIN format, credit note references - Persist —
serializer.save()createsReceipt+ReceiptLinerows inside@transaction.atomic - Ensure fiscal day open — auto-opens if none found
- Get next global number — queries
GET /getStatusfrom FDMS - Build receipt data — lines, tax groups, previous hash (chain), signature string
- Hash & sign —
SHA256+RSA PKCS1v15viaZIMRACrypto - QR code — MD5 of signature bytes → verification code → PNG saved
- Update fiscal counters — atomic
F()increments per tax group - Submit to FDMS —
POST /SubmitReceipt; if rejected, the@transaction.atomicrolls back everything
Cryptography
Library: cryptography (replaces deprecated pyOpenSSL).
Receipt signature string (spec §13.2.1)
QR code generation
verification_code = MD5(signature_bytes).hexdigest()[:16].upper()
# formatted as XXXX-XXXX-XXXX-XXXX
qr_url = f"{fdms_base}/{device_id}{date}{global_no}{code}"
Development guidelines
Adding a new service
class MyService:
def __init__(self, device: Device):
self.device = device
self.client = ZIMRAClient(device)
@transaction.atomic
def do_thing(self) -> dict:
try:
result = self.client.some_endpoint()
except requests.RequestException as exc:
raise MyError("FDMS call failed") from exc
MyModel.objects.create(device=self.device, ...)
return result
Testing
pytest # all tests
pytest --cov=fiscguy --cov-report=html # with coverage
pytest -k "test_build_sale_by_tax" # single test
ZIMRAClient, ZIMRACrypto, and requests. Never make real FDMS calls in tests.Code style
black fiscguy && isort fiscguy && flake8 fiscguy && mypy fiscguy
- Line length: 100 (Black)
- Imports: isort with Black profile
- Private methods: prefix
_ - Type hints on all public method signatures
- Docstrings on all public classes and methods