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).

Python 3.11 · 3.12 · 3.13 Django 4.2+ DRF 3.14+ MIT License

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

RequirementVersion
Python3.11, 3.12, or 3.13
Django4.2+
Django REST Framework3.14+

Quick start

1

Install

pip install fiscguy
2

Add to INSTALLED_APPS and run migrations

# settings.py
INSTALLED_APPS = ["rest_framework", "fiscguy", ...]
python manage.py migrate
3

Register your device

python manage.py init_device
4

Open a fiscal day and submit a receipt

POST /fiscguy/open-day/
POST /fiscguy/receipts/

FDMS environments

EnvironmentURL
Testinghttps://fdmsapitest.zimra.co.zw
Productionhttps://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

PromptExampleDescription
Environmentyes / noyes = production, no = test
Organisation nameAcme LtdRegistered company name
Device ID78412Provided by ZIMRA
Activation keyABC-123-XYZProvided by ZIMRA
Device model nameServerProvided by ZIMRA
Device model version1.0Provided by ZIMRA
Device serial numberSN0001Provided by ZIMRA
⚠️
Switching environmentsRe-running 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

CounterChange
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"
    }
  ]
}
⚠️
ZIMRA rules for credit notes 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

ValueDescription
CashPhysical cash
CardCredit or debit card
MobileWalletMobile money (EcoCash, etc.)
BankTransferDirect bank transfer
CouponVoucher or coupon
CreditCredit account
OtherAny 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 IDNamePercent
1Exempt0%
2Zero Rated0%
3+Standard Rated15.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:

  1. Build counter strings — each counter is serialised into the ZIMRA closing string format
  2. Assemble the closing string — deviceID + fiscalDayNo + fiscalDayDate + counters
  3. Hash and sign — SHA-256 hash of the string, signed with the device RSA private key
  4. Build payload — closing string, signature, fiscal day counters, receipt counter
  5. Submit to FDMS — POST /CloseDay
  6. Poll for status — waits 10 seconds then calls GET /getStatus
  7. Update database — marks FiscalDay.is_open = False on success

Closing string spec

Fields concatenated in this exact order, all text uppercase, no separators:

OrderFieldFormat
1deviceIDInteger as-is
2fiscalDayNoInteger as-is
3fiscalDayDateYYYY-MM-DDdate the fiscal day was opened, not today
4fiscalDayCountersConcatenated counter string (see below)
🔴
CriticalfiscalDayDate 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

RuleDetail
Uppercase.upper() applied to the entire string
Amounts in centsint(round(value × 100)) — preserves sign
Tax percent formatAlways two decimal places: 1515.00, 14.514.50
Exempt taxEmpty string — nothing between currency and value
BalanceByMoneyTypeLiteral L between currency and money type, e.g. USDLCASH
Zero-value countersExcluded entirely (spec section 4.11)
Sort orderType enum ASC → currency ASC → taxID / moneyType ASC

Example counter string

23265842026-03-30SALEBYTAXZWG0.005000SALEBYTAXZWG15.50134950SALETAXBYTAXZWG15.5018110BALANCEBYMONEYTYPEZWGLCARD69975BALANCEBYMONEYTYPEZWGLCASH69975

Common close day errors

ErrorRoot cause
CountersMismatchWrong date, missing L, wrong tax percent format, unsorted counters, or zero counters included
BadCertificateSignatureCertificate expired or wrong private key used
FiscalDayCloseFailedFDMS validation failed — check fiscalDayClosingErrorCode

Fiscal day status values

StatusMeaning
FiscalDayOpenedDay is open, receipts can be submitted
FiscalDayCloseInitiatedClose request submitted, processing
FiscalDayClosedDay closed successfully
FiscalDayCloseFailedClose 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

CounterTracksBy TaxBy CurrencyBy Payment
SaleByTaxSales amount including tax
SaleTaxByTaxTax portion of sales
CreditNoteByTaxTotal credit note amounts
CreditNoteTaxByTaxTax portion of credit notes
DebitNoteByTaxTotal debit note amounts
DebitNoteTaxByTaxTax portion of debit notes
BalanceByMoneyTypeTotal 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",
))
ℹ️
Counters reset automatically when a fiscal day is closed. The next 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

  1. init_device generates an RSA key pair and a Certificate Signing Request (CSR)
  2. The CSR is sent to ZIMRA FDMS RegisterDevice endpoint
  3. ZIMRA returns a signed certificate
  4. The certificate and private key are stored in the Certs model
  5. 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

AlgorithmSpec reference
RSA 2048Section 12.1.2
ECC ECDSA secp256r1 (P-256)Section 12.1.1

Security notes

🔒
Private key storageThe private key is stored plaintext in 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

POST open-day/ Open 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.

200 OK Success
{ "success": true, "fiscal_day_no": 42, "fdms_response": { ... } }
200 OK Already open
{ "success": true, "fiscal_day_no": 42, "message": "Fiscal day 42 already open" }
POST close-day/ Close Fiscal Day

Closes the currently open fiscal day. Builds fiscal counters, signs the closing payload, and submits to FDMS.

200 OK
{ "success": true, "fiscal_day_no": 42, "fdms_response": { ... } }
422 ZIMRA rejected
{ "error": "FDMS returned an error: ..." }

Receipts

GET receipts/ List Receipts

Returns all receipts in reverse chronological order with cursor-based pagination.

Query parameters

ParameterTypeDefaultDescription
cursorstringPagination cursor from a previous response
page_sizeinteger10Results per page (max 100)
200 OK
{
  "next": "http://example.com/fiscguy/receipts/?cursor=cD0...",
  "previous": null,
  "results": [{ ...receipt object... }]
}
POST receipts/ Create & Submit Receipt

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

FieldTypeDescription
receipt_type*stringfiscalinvoice, creditnote, or debitnote
total_amount*decimalNegative for credit notes
currency*stringUSD or ZWG
payment_terms*stringCash, Card, MobileWallet, BankTransfer, Coupon, Credit, Other
lines*arrayOne or more line items
buyerobjectFull buyer object. Omit for anonymous sales.
credit_note_referencestringRequired for creditnote
credit_note_reasonstringHuman-readable reason
201 Created

Returns the full receipt object including receipt_number, hash_value, signature, zimra_inv_id, and qr_code.

422 ZIMRA rejected
{ "error": "FDMS returned an error: ..." }

Buyers

GET buyer/ List Buyers
200 OK
[{
  "id": 1,
  "name": "Casy Holdings",
  "tin_number": "2000123456",
  "address": "123 Main St, Harare",
  "trade_name": "Casy Retail",
  "email": "accounts@casy.co.zw",
  "phonenumber": "+263771234567"
}]
POST buyer/ Create Buyer
FieldTypeDescription
name*stringRegistered business name
tin_number*string10-digit ZIMRA TIN number
addressstringPhysical address
trade_namestringTrading name / branch name
emailstringContact email
phonenumberstringContact phone number
201 Created
GET buyer/{id}/ Retrieve / Update / Delete Buyer

Also supports PUT for full update, PATCH for partial update, and DELETE to remove a buyer.

Configuration & Taxes

GET configuration/ Get Configuration
{
  "tax_payer_name": "ACME Ltd",
  "tin_number": "1234567890",
  "vat_number": "V123456789",
  "address": "10 Commerce Drive, Harare"
}
POST sync-config/ Sync Configuration

Pulls the latest configuration from ZIMRA FDMS and updates the local database. Also called automatically when opening a fiscal day.

{ "message": "Configuration Synced" }
GET taxes/ List Taxes
[
  { "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

GET get-status/ Device Status
{ "lastFiscalDayNo": 41, "lastReceiptGlobalNo": 150, "fiscalDayStatus": "Closed" }
POST get-ping/ Ping Device

Confirms the device is online and communicating with FDMS correctly.

{ "success": true }
POST issue-certificate/ Issue / Renew Certificate

Renews the device certificate with ZIMRA FDMS. Use when the current certificate has expired.

200 OK
{ "message": "Certificate issued successfully" }
422
{ "error": "Certificate renewal issuance failed." }

HTTP status codes

StatusMeaning
200 OKRequest succeeded
201 CreatedReceipt created and submitted
400 Bad RequestInvalid input — fiscal day already open, etc.
404 Not FoundNo device registered
422 Unprocessable EntityZIMRA rejected the request
500 Internal Server ErrorUnexpected server error

Error Reference

All FiscGuy exceptions inherit from FiscalisationError. Import them from fiscguy.exceptions.

Exception hierarchy

FiscalisationError ├── ReceiptSubmissionError ├── CloseDayError ├── FiscalDayError ├── ConfigurationError ├── CertificateError ├── DevicePingError ├── StatusError ├── DeviceRegistrationError ├── CryptoError ├── CertNotFoundError ├── PersistenceError ├── ZIMRAAPIError ├── ValidationError ├── AuthenticationError ├── TaxError ├── DeviceNotFoundError └── ZIMRAClientError

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 codeCauseFix
CountersMismatchClosing string counters don't match FDMSCheck date, tax percent format, and sort order
BadCertificateSignatureCertificate expired or wrong keyRenew certificate via POST /fiscguy/issue-certificate/
FiscalDayCloseFailedFDMS validation failedCheck 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")
LevelEvent
INFOReceipt submitted, day opened/closed, client initialised
WARNINGFDMS offline (receipt queued), global number mismatch
ERRORReceipt submission failed, close day failed
EXCEPTIONUnexpected errors with full traceback

Troubleshooting

ErrorFix
RuntimeError: No Device foundRun python manage.py init_device
RuntimeError: ZIMRA configuration missingPOST /fiscguy/sync-config/
MalformedFraming: Unable to load PEM fileRe-run init_device — certificate corrupted
No open fiscal dayPOST /fiscguy/open-day/
Certificate expiredPOST /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

┌─────────────────────────────────────────────┐ │ Host Django Application │ │ │ │ urlpatterns += [path("fiscguy/", ...)] │ └───────────────────┬─────────────────────────┘ │ ┌───────────▼──────────┐ │ FiscGuy Library │ │ REST API → Services │ │ Services → ZIMRA │ │ Services → DB │ └───────────┬──────────┘ │ HTTPS + mTLS ┌───────────▼──────────┐ │ ZIMRA FDMS │ │ fdmsapitest / prod │ └──────────────────────┘

Layer architecture

FiscGuy follows a strict four-layer architecture. Each layer has a single responsibility and communicates only with the layer directly below it.

┌──────────────────────────────────────────────┐ │ REST API LAYER · views.py │ │ HTTP in → validate → delegate to service │ │ Handle typed exceptions → DRF Response │ │ Never contains business logic │ ├──────────────────────────────────────────────┤ │ SERVICE LAYER · services/ │ │ ReceiptService OpenDayService │ │ ClosingDayService ConfigurationService │ │ All business logic, atomic transactions. │ │ Raises typed FiscalisationError subclasses │ ├──────────────────────────────────────────────┤ │ ZIMRA INTEGRATION LAYER · zimra_*.py │ │ ZIMRAClient HTTP to FDMS, mTLS │ │ ZIMRAReceiptHandler Full receipt pipeline │ │ ZIMRACrypto RSA, SHA-256, MD5, QR │ ├──────────────────────────────────────────────┤ │ DATA LAYER · models.py │ │ Device Configuration Certs FiscalDay │ │ FiscalCounter Receipt ReceiptLine Taxes │ └──────────────────────────────────────────────┘ │ SQLite / PostgreSQL / MySQL

Receipt processing pipeline

A POST /fiscguy/receipts/ call flows through these steps:

  1. ValidationReceiptCreateSerializer.is_valid() checks types, amounts, TIN format, credit note references
  2. Persistserializer.save() creates Receipt + ReceiptLine rows inside @transaction.atomic
  3. Ensure fiscal day open — auto-opens if none found
  4. Get next global number — queries GET /getStatus from FDMS
  5. Build receipt data — lines, tax groups, previous hash (chain), signature string
  6. Hash & signSHA256 + RSA PKCS1v15 via ZIMRACrypto
  7. QR code — MD5 of signature bytes → verification code → PNG saved
  8. Update fiscal counters — atomic F() increments per tax group
  9. Submit to FDMSPOST /SubmitReceipt; if rejected, the @transaction.atomic rolls back everything

Cryptography

Library: cryptography (replaces deprecated pyOpenSSL).

Receipt signature string (spec §13.2.1)

{deviceID} {receiptType} ← UPPERCASE e.g. FISCALINVOICE {receiptCurrency} ← UPPERCASE e.g. USD {receiptGlobalNo} {receiptDate} ← YYYY-MM-DDTHH:mm:ss {receiptTotal_cents} ← negative for credit notes {receiptTaxes} ← concatenated, ordered by taxID ASC {previousReceiptHash} ← omitted if first receipt of day

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
ℹ️
Mock external calls at the boundary — patch 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
🔒
Internal use onlyDo not publish this document. Maintainer: Casper Moyo · cassymyo@gmail.com
On this page