Skip to content

Commit 7e64ace

Browse files
committed
ui
1 parent 1804506 commit 7e64ace

55 files changed

Lines changed: 2367 additions & 165 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎MANIFEST.in‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ recursive-include fiscguy *.sql
99
recursive-include fiscguy/migrations *.py
1010
recursive-include fiscguy/management *.py
1111
recursive-include fiscguy/tests *.py
12+
recursive-include fiscguy/templates *.html
13+
recursive-include fiscguy/static *
1214

1315
global-exclude __pycache__
1416
global-exclude *.pyc

‎README.md‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
1010
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
1111

12-
<a href="https://otene.co.zw/projects/pypi" target="_blank" rel="noopener"><img src="https://otene.co.zw/badge/pypi" alt="Fiscguy on Otene" width="206" height="54" /></a>
13-
1412
**The Modern Python Library for ZIMRA Fiscal Device Integration**
1513

1614
FiscGuy gives Django applications a simple, Pythonic interface for every fiscal operation required by the Zimbabwe Revenue Authority — receipt submission, fiscal day management, certificate handling, and more. Built on Django REST Framework, it drops into any Django project in minutes.
@@ -173,10 +171,17 @@ For frontend integrations, call `POST /fiscguy/close-day/` once, then poll
173171
`GET /fiscguy/get-status/` until the fiscal day moves from `close_pending` to `closed` or
174172
`close_failed`.
175173

174+
### Web UI
175+
176+
Optional pages for registration, certificate upload, fiscal day control and receipts, protected by
177+
login, permissions, password re-confirmation, CSRF, throttling and an audit log. Off by default;
178+
see [docs/web-ui.md](docs/web-ui.md). The JSON endpoints are staff-only unless you set
179+
`FISCGUY_PERMISSION_CLASSES`.
180+
176181
### HS Code Mapping
177182

178183
FiscGuy supports `hs_code` on receipt lines and passes it through to FDMS as
179-
`receiptLineHSCode`.
184+
`receiptLineHSCode`. It is optional; a blank code is sent as-is.
180185

181186
Firms should map their product and service catalogues to the correct FDMS-compatible HS codes
182187
before going live. In practice, this means:

‎docs/changelog.md‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,68 @@
33
All notable changes to Fiscguy are documented in this file.
44
Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and [Semantic Versioning](https://semver.org/).
55

6+
## [0.2.0] - 2026-10-22
7+
8+
### Added
9+
- Optional web UI (`fiscguy.web.urls`): device list, registration with an activation key,
10+
registration with an uploaded certificate and key, fiscal day open/close with status polling,
11+
certificate renewal, configuration sync and receipt browsing. Off unless `FISCGUY_UI_ENABLED`.
12+
See `docs/web-ui.md`.
13+
- Permissions `manage_device`, `close_day` and `view_receipts`.
14+
- `AuditLog` model (read-only in admin) recording registrations, certificate changes and day open/close.
15+
- Services `DeviceRegistrationService`, `ManualDeviceRegistrationService` and `cert_bundle`
16+
(certificate and key parsing, validation, expiry and key-match checks).
17+
- `FISCGUY_PERMISSION_CLASSES`, `FISCGUY_ACCESS_CHECK`, `FISCGUY_DEVICE_QUERYSET`,
18+
`FISCGUY_CLOSE_POLL`, `FISCGUY_LOG_FDMS_PAYLOADS` and `FISCGUY_REAUTH_MAX_AGE` settings.
19+
- System checks `fiscguy.W001` (no dedicated encryption key) and `fiscguy.W002` (UI without secure cookies).
20+
- Optional `?device_id=` selection on the JSON endpoints.
21+
- Migrations `0015` and `0016`.
22+
23+
### Changed
24+
- **Breaking:** JSON endpoints now require staff by default instead of being open. Set
25+
`FISCGUY_PERMISSION_CLASSES` to your own classes to change this.
26+
- Device activation keys are encrypted at rest (existing values are encrypted by migration `0016`)
27+
and hidden from the Django admin.
28+
- FDMS request and response bodies are no longer logged by default; error responses are logged
29+
truncated. Enable `FISCGUY_LOG_FDMS_PAYLOADS` to log bodies at DEBUG.
30+
- `FISCGUY_CLOSE_POLL = "client"` skips the background close-day thread. The default is unchanged.
31+
- Open day, close day and certificate renewal through the API are audited.
32+
33+
## [0.1.9] - 2026-10-22
34+
35+
### Added
36+
- `ZIMRA_PROXY` environment variable to route FDMS traffic through a proxy.
37+
- `Taxes.is_standard`, set at config sync by matching the tax name (ZIMRA taxIDs are per
38+
taxpayer), falling back to the highest non-withholding rate.
39+
- `Receipt.fiscal_day_no`, stamped at submission and exposed in the receipt serializer.
40+
- `qr_code_data` (base64 PNG data URI) on receipt responses.
41+
- Nested `tax_type` (code, name, percent) on receipt lines in receipt responses.
42+
- `has_validation_errors` in the submit result when FDMS returns red validation errors; the
43+
receipt is kept and the errors are logged.
44+
- `GET /fiscguy/receipts/{id}/` is now routed.
45+
- Tests for by-tax close-day payloads, standard-tax detection, proxy opt-in and device-scoped signing.
46+
- Migration `0014`.
47+
48+
### Changed
49+
- Receipt numbers and `invoiceNo` are now `<device_id>-<global_no>` instead of `R-<global_no>`.
50+
`receipt_number` is unique per device rather than globally.
51+
- `ZIMRACrypto` signs with the certificate of the given `device`, and raises `CryptoError`
52+
without a device or `private_key_path` instead of using any stored certificate.
53+
- Credit and debit note references resolve against the same device only.
54+
- Opening a fiscal day is refused while the open day is `close_pending` or `close_failed`.
55+
- A new device with no `lastFiscalDayNo` or `lastReceiptGlobalNo` from FDMS is treated as 0.
56+
- `hs_code` is no longer required on fiscal invoice lines.
57+
- `DevicePing` returns `online: true`, or `online: false` with 503 when FDMS is unreachable.
58+
Pings time out after 8 seconds.
59+
- FDMS requests and responses are logged in full, including the response body on errors.
60+
- `DeviceSerializer.activation_key` is write-only.
61+
- `init_device` looks up the certificate by the device being registered.
62+
63+
### Fixed
64+
- `ReceiptDetailView` queried a non-existent `receipt_lines` relation.
65+
- Creating a buyer with only a name and TIN raised `KeyError`.
66+
- The buyer router was registered as `buyer/`, producing a broken URL pattern.
67+
668
## [0.1.8] - 2026-05-03
769

870
### Added

‎docs/installation.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,11 @@ To switch environments, re-run `init_device`.
142142

143143
---
144144

145+
## Routing FDMS traffic through a proxy
146+
147+
Set `ZIMRA_PROXY` (for example `http://host:8889`) to send FDMS calls through a proxy. It is
148+
read only for ZIMRA traffic and is not the same as `HTTPS_PROXY`. Leave it unset to connect directly.
149+
145150
## Development Dependencies
146151

147152
```bash

‎docs/receipt-types.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ receipt = submit_receipt({
5656
"total_amount": "-115.00", # Must be <= 0
5757
"payment_terms": "Cash",
5858
"credit_note_reason": "Customer returned goods — defective",
59-
"credit_note_reference": "R-00000142", # Original receipt number
59+
"credit_note_reference": "41872-00000142", # Original receipt number
6060
"lines": [
6161
{
6262
"product": "Product Name",
@@ -105,7 +105,7 @@ receipt = submit_receipt({
105105
"total_amount": "23.00",
106106
"payment_terms": "Card",
107107
"debit_note_reason": "Additional delivery charge",
108-
"debit_note_reference": "R-00000142",
108+
"debit_note_reference": "41872-00000142",
109109
"lines": [
110110
{
111111
"product": "Delivery fee",
@@ -181,7 +181,8 @@ recommended flow is:
181181

182182
### HS Code Guidance
183183

184-
- `hs_code` should be included in receipt-line payloads for normal invoice flows
184+
- `hs_code` should be included in receipt-line payloads for normal invoice flows; it is optional
185+
and a blank code is sent through rather than rejecting the sale
185186
- Firms should map their product and service catalogues to the correct FDMS-compatible HS codes
186187
- Credit notes can inherit `hs_code` from the referenced original receipt line
187188
- Debit notes can inherit `hs_code` from the referenced original receipt line or, for

‎docs/web-ui.md‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Web UI and security
2+
3+
Fiscguy ships optional pages for device registration, certificate upload, fiscal day control,
4+
status polling and receipts. They are off by default.
5+
6+
## Enable
7+
8+
```python
9+
# settings.py
10+
INSTALLED_APPS += ["django.contrib.sessions", "django.contrib.messages", "fiscguy"]
11+
FISCGUY_UI_ENABLED = True
12+
FISCGUY_ENCRYPTION_KEY = "<Fernet key>" # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
13+
SESSION_COOKIE_SECURE = True
14+
CSRF_COOKIE_SECURE = True
15+
16+
# urls.py
17+
path("fiscal/", include("fiscguy.web.urls")),
18+
```
19+
20+
The pages use the host's `LOGIN_URL`. Templates live under `fiscguy/` and can be overridden by
21+
placing files with the same names in your own `templates/fiscguy/` directory.
22+
23+
## Settings
24+
25+
| Setting | Default | Purpose |
26+
|---|---|---|
27+
| `FISCGUY_UI_ENABLED` | `False` | Turns the pages on. When off they return 404. |
28+
| `FISCGUY_PERMISSION_CLASSES` | `["rest_framework.permissions.IsAdminUser"]` | DRF permissions for every JSON endpoint. |
29+
| `FISCGUY_ACCESS_CHECK` | none | `callable(request, action) -> bool` that replaces the page permission check. |
30+
| `FISCGUY_DEVICE_QUERYSET` | none | `callable(request) -> QuerySet[Device]` limiting which devices a request can see or act on. Use it to scope devices per customer, branch or user. |
31+
| `FISCGUY_CLOSE_POLL` | `"background"` | `"client"` stops the server thread; the page (or your frontend) polls `get-status` instead. |
32+
| `FISCGUY_LOG_FDMS_PAYLOADS` | `False` | Logs FDMS request and response bodies at DEBUG. They contain buyer TINs and receipt data. |
33+
| `FISCGUY_REAUTH_MAX_AGE` | `300` | Seconds a password confirmation stays valid. |
34+
| `FISCGUY_ENCRYPTION_KEY` | derived from `SECRET_KEY` | Fernet key for certificate keys and activation keys. |
35+
36+
## Permissions
37+
38+
| Permission | Allows |
39+
|---|---|
40+
| `fiscguy.view_receipts` | Dashboard, device pages, receipts, status and ping |
41+
| `fiscguy.close_day` | Open and close fiscal days |
42+
| `fiscguy.manage_device` | Register devices, upload certificates, renew, sync configuration, audit log |
43+
44+
Superusers have all three. The JSON API is staff-only by default because it can submit receipts
45+
and close days; the old behaviour of open endpoints is gone. Set `FISCGUY_PERMISSION_CLASSES` to
46+
use your own classes.
47+
48+
## What protects the pages
49+
50+
- Feature flag, login and a specific permission on every view. Nothing is public.
51+
- Device lookups go through `FISCGUY_DEVICE_QUERYSET`, so a guessed device or receipt ID returns 404.
52+
- Every state change is POST-only with CSRF protection.
53+
- Registering, uploading certificates and renewing certificates need the user's password again
54+
(valid for `FISCGUY_REAUTH_MAX_AGE` seconds). Switching a device between test and production
55+
deletes its history and needs the device ID typed to confirm.
56+
- Per-user throttling on registration, actions, status, ping and password confirmation.
57+
- Responses carry `Cache-Control: no-store`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`
58+
and a CSP that allows only same-origin scripts and styles. The templates have no inline script or style.
59+
- Keys and activation keys are write-only: never rendered back (even on a failed form), never logged,
60+
encrypted at rest. Uploads are capped at 64 KB, read in memory, validated and never written to disk.
61+
- Passphrase-protected keys, expired certificates and key/certificate mismatches are rejected.
62+
- Every register, upload, renew, open day and close day is written to `AuditLog` (user, time, device, IP).
63+
The log is read-only in the Django admin.
64+
65+
`manage.py check` warns when `FISCGUY_ENCRYPTION_KEY` is unset (`fiscguy.W001`) and when the UI is
66+
enabled without secure cookies (`fiscguy.W002`).
67+
68+
## Close day without a background thread
69+
70+
With `FISCGUY_CLOSE_POLL = "client"`, `POST close-day/` returns as soon as FDMS accepts the request.
71+
Poll `GET get-status/` (API) or `devices/<id>/status/` (UI) until the day is `closed` or `close_failed`.
72+
The device page does this automatically.

‎fiscguy/admin.py‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.contrib import admin
22

33
from fiscguy.models import (
4+
AuditLog,
45
Certs,
56
Configuration,
67
Device,
@@ -26,7 +27,26 @@ def csr_preview(obj):
2627

2728
admin.site.register(Configuration)
2829
admin.site.register(Taxes)
29-
admin.site.register(Device)
30+
@admin.register(Device)
31+
class DeviceAdmin(admin.ModelAdmin):
32+
list_display = ("device_id", "org_name", "production", "created_at")
33+
exclude = ("activation_key",)
3034
admin.site.register(FiscalDay)
3135
admin.site.register(Receipt)
3236
admin.site.register(FiscalCounter)
37+
38+
39+
@admin.register(AuditLog)
40+
class AuditLogAdmin(admin.ModelAdmin):
41+
list_display = ("created_at", "actor_name", "action", "device_ref", "ip_address")
42+
list_filter = ("action",)
43+
readonly_fields = [f.name for f in AuditLog._meta.fields]
44+
45+
def has_add_permission(self, request):
46+
return False
47+
48+
def has_change_permission(self, request, obj=None):
49+
return False
50+
51+
def has_delete_permission(self, request, obj=None):
52+
return False

‎fiscguy/apps.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from django.apps import AppConfig
22

33

4-
#
54
class FiscguyConfig(AppConfig):
65
name = "fiscguy"
6+
default_auto_field = "django.db.models.BigAutoField"
7+
8+
def ready(self):
9+
from fiscguy import checks # noqa: F401

‎fiscguy/audit.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from loguru import logger
2+
3+
from fiscguy.models import AuditLog
4+
5+
6+
def client_ip(request) -> str | None:
7+
return request.META.get("REMOTE_ADDR") or None
8+
9+
10+
def record(request, action: str, device=None, detail: str = "") -> None:
11+
"""Write an audit row. Never raises and never receives key material."""
12+
try:
13+
user = getattr(request, "user", None)
14+
authed = bool(user and user.is_authenticated)
15+
AuditLog.objects.create(
16+
actor=user if authed else None,
17+
actor_name=(user.get_username() if authed else "")[:150],
18+
action=action[:64],
19+
device=device,
20+
device_ref=(device.device_id if device else "")[:100],
21+
detail=detail[:255],
22+
ip_address=client_ip(request),
23+
)
24+
except Exception:
25+
logger.exception(f"Failed to write audit log for {action}")

‎fiscguy/checks.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from django.conf import settings
2+
from django.core.checks import Warning, register
3+
4+
from fiscguy import conf
5+
6+
7+
@register()
8+
def fiscguy_security_checks(app_configs, **kwargs):
9+
messages = []
10+
if not getattr(settings, "FISCGUY_ENCRYPTION_KEY", None):
11+
messages.append(
12+
Warning(
13+
"FISCGUY_ENCRYPTION_KEY is not set; certificate keys and activation keys are "
14+
"encrypted with a key derived from SECRET_KEY.",
15+
hint="Set a dedicated Fernet key in FISCGUY_ENCRYPTION_KEY and keep it out of the database backups.",
16+
id="fiscguy.W001",
17+
)
18+
)
19+
if conf.ui_enabled() and not settings.DEBUG:
20+
if not getattr(settings, "SESSION_COOKIE_SECURE", False) or not getattr(
21+
settings, "CSRF_COOKIE_SECURE", False
22+
):
23+
messages.append(
24+
Warning(
25+
"FISCGUY_UI_ENABLED is on but SESSION_COOKIE_SECURE or CSRF_COOKIE_SECURE is off.",
26+
hint="Serve the UI over HTTPS and set both to True.",
27+
id="fiscguy.W002",
28+
)
29+
)
30+
return messages

0 commit comments

Comments
 (0)