From 328049023f7857ba56b9f9f70b068cb9e7453166 Mon Sep 17 00:00:00 2001 From: Sam-24-dev Date: Tue, 1 Sep 2026 03:40:28 +0100 Subject: [PATCH] feat(api): expose fiscal configuration --- backend/api/fiscal_views.py | 49 +++++++++++++ backend/api/tests.py | 140 ++++++++++++++++++++++++++++++++++++ backend/api/urls.py | 2 + backend/fiscal/services.py | 8 ++- 4 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 backend/api/fiscal_views.py diff --git a/backend/api/fiscal_views.py b/backend/api/fiscal_views.py new file mode 100644 index 0000000..a84a0c1 --- /dev/null +++ b/backend/api/fiscal_views.py @@ -0,0 +1,49 @@ +"""Read-only active-workspace fiscal configuration endpoint.""" + +from django.utils.decorators import method_decorator + +from fiscal.services import ( + FiscalAccessDenied, + FiscalConfigurationNotConfigured, + get_current_fiscal_configuration, +) +from workspaces.context import WorkspaceContextError, resolve_active_workspace_context +from workspaces.permissions import WorkspacePermissionDenied + +from .auth_views import JsonMethodView, require_api_auth +from .http import json_data, json_error + + +READ_FIELDS = ( + "public_id", + "version", + "legal_name", + "tax_identifier", + "tax_regime", + "applies_vat", + "vat_rate", + "withholding_rate", +) + + +def _serialize(configuration): + return {field: getattr(configuration, field) for field in READ_FIELDS} + + +class FiscalConfigurationView(JsonMethodView): + @method_decorator(require_api_auth) + def get(self, request): + if request.GET: + return json_error("invalid_request", status=400) + try: + context = resolve_active_workspace_context(request) + configuration = get_current_fiscal_configuration(context) + except WorkspacePermissionDenied: + return json_error("permission_denied", status=403) + except FiscalAccessDenied: + return json_error("permission_denied", status=403) + except FiscalConfigurationNotConfigured: + return json_error("fiscal_configuration_not_configured", status=404) + except WorkspaceContextError: + return json_error("workspace_required", status=400) + return json_data(_serialize(configuration)) diff --git a/backend/api/tests.py b/backend/api/tests.py index 9b4d125..3d4d1e4 100644 --- a/backend/api/tests.py +++ b/backend/api/tests.py @@ -120,6 +120,146 @@ def test_logout_is_csrf_protected_and_flushes_session(self): self.assertEqual(dict(csrf_client.session.items()), {}) +class FiscalConfigurationApiTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + email="fiscal-api@example.com", password="correct-horse-battery-staple" + ) + self.other_user = get_user_model().objects.create_user( + email="fiscal-other-api@example.com", password="correct-horse-battery-staple" + ) + self.workspace = Workspace.objects.create(name="Fiscal API Studio", slug="fiscal-api-studio") + self.membership = Membership.objects.create( + workspace=self.workspace, user=self.user, role=Membership.Role.OWNER + ) + self.foreign_workspace = Workspace.objects.create( + name="Foreign Fiscal API Studio", slug="foreign-fiscal-api-studio" + ) + Membership.objects.create( + workspace=self.foreign_workspace, user=self.other_user, role=Membership.Role.OWNER + ) + + def authenticate(self, *, expires_at=1_000_100, workspace=None, user=None): + self.client.force_login(user or self.user) + session = self.client.session + session["api.auth_expires_at"] = expires_at + if workspace is not None: + session["workspaces.active_workspace_public_id"] = str(workspace.public_id) + session.save() + + def create_configuration(self, context=None, **overrides): + context = context or ActiveWorkspaceContext(self.workspace, self.membership) + payload = { + "legal_name": "Fiscal API Studio", + "tax_identifier": "EC-FISCAL-1", + "tax_regime": "GENERAL", + "applies_vat": True, + "vat_rate": Decimal("15.00"), + "withholding_rate": Decimal("2.00"), + } + payload.update(overrides) + return create_fiscal_configuration(context, **payload) + + @patch("api.auth_views.time.time", return_value=1_000_000) + def test_requires_authenticated_live_session_and_active_workspace_context(self, mocked_time): + self.assertEqual(self.client.get("/api/v1/fiscal-configuration/").status_code, 401) + self.authenticate(expires_at=999_999, workspace=self.workspace) + self.assertEqual(self.client.get("/api/v1/fiscal-configuration/").status_code, 401) + self.authenticate() + response = self.client.get("/api/v1/fiscal-configuration/") + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {"error": {"code": "workspace_required"}}) + self.authenticate(workspace=self.foreign_workspace) + response = self.client.get("/api/v1/fiscal-configuration/") + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {"error": {"code": "workspace_required"}}) + + @patch("api.auth_views.time.time", return_value=1_000_000) + def test_allows_only_owner_and_operational_memberships_with_fresh_role(self, mocked_time): + self.authenticate(workspace=self.workspace) + self.create_configuration() + for role, status in ( + (Membership.Role.OWNER, 200), + (Membership.Role.OPERATIONAL, 200), + (Membership.Role.ADMINISTRATIVE, 403), + ): + with allow_membership_writes(): + Membership.objects.filter(pk=self.membership.pk).update(role=role) + response = self.client.get("/api/v1/fiscal-configuration/") + self.assertEqual(response.status_code, status) + superuser = get_user_model().objects.create_superuser( + email="fiscal-superuser-api@example.com", password="correct-horse-battery-staple" + ) + self.authenticate(workspace=self.workspace, user=superuser) + response = self.client.get("/api/v1/fiscal-configuration/") + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {"error": {"code": "workspace_required"}}) + + @patch("api.auth_views.time.time", return_value=1_000_000) + def test_returns_only_current_configuration_for_active_tenant(self, mocked_time): + first = self.create_configuration(legal_name="Old Name") + current = self.create_configuration(legal_name="Current Name") + foreign_context = ActiveWorkspaceContext( + self.foreign_workspace, + Membership.objects.get(workspace=self.foreign_workspace, user=self.other_user), + ) + foreign = self.create_configuration(foreign_context, legal_name="Foreign Name") + self.authenticate(workspace=self.workspace) + + response = self.client.get("/api/v1/fiscal-configuration/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Cache-Control"], "no-store") + item = response.json()["data"] + self.assertEqual(set(item), { + "public_id", "version", "legal_name", "tax_identifier", "tax_regime", + "applies_vat", "vat_rate", "withholding_rate", + }) + self.assertEqual(item["public_id"], str(current.public_id)) + self.assertEqual(item["version"], current.version) + self.assertEqual(item["legal_name"], "Current Name") + self.assertEqual(item["vat_rate"], "15.00") + self.assertEqual(item["withholding_rate"], "2.00") + for forbidden in ( + "pk", "id", "workspace", "workspace_id", "created_at", "updated_at", + "historical_versions", "invoice_data", + ): + self.assertNotIn(forbidden, item) + self.assertNotEqual(first.public_id, current.public_id) + self.assertNotEqual(foreign.public_id, current.public_id) + + @patch("api.auth_views.time.time", return_value=1_000_000) + def test_decimal_rates_are_json_strings_without_float_coercion(self, mocked_time): + self.create_configuration(vat_rate=Decimal("12.34"), withholding_rate=Decimal("0.01")) + self.authenticate(workspace=self.workspace) + + item = self.client.get("/api/v1/fiscal-configuration/").json()["data"] + + self.assertIsInstance(item["vat_rate"], str) + self.assertIsInstance(item["withholding_rate"], str) + self.assertEqual(item["vat_rate"], "12.34") + self.assertEqual(item["withholding_rate"], "0.01") + + @patch("api.auth_views.time.time", return_value=1_000_000) + def test_authorized_workspace_without_configuration_returns_contract_404(self, mocked_time): + self.authenticate(workspace=self.workspace) + response = self.client.get("/api/v1/fiscal-configuration/") + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json(), {"error": {"code": "fiscal_configuration_not_configured"}}) + + @patch("api.auth_views.time.time", return_value=1_000_000) + def test_rejects_query_parameters_and_non_get_methods_as_json(self, mocked_time): + self.authenticate(workspace=self.workspace) + response = self.client.get("/api/v1/fiscal-configuration/?workspace=ignored") + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {"error": {"code": "invalid_request"}}) + response = self.client.post("/api/v1/fiscal-configuration/") + self.assertEqual(response.status_code, 405) + self.assertEqual(response.json(), {"error": {"code": "method_not_allowed"}}) + self.assertEqual(response["Allow"], "GET, HEAD, OPTIONS") + self.assertEqual(response["Cache-Control"], "no-store") + + class WorkspaceApiTests(TestCase): def setUp(self): self.user = get_user_model().objects.create_user( diff --git a/backend/api/urls.py b/backend/api/urls.py index 6ad3350..f43422d 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -3,6 +3,7 @@ from .auth_views import SessionLoginView, SessionLogoutView, SessionView from .client_views import ClientListView from .invoice_views import InvoiceListView +from .fiscal_views import FiscalConfigurationView from .notification_views import NotificationListView from .payment_views import PaymentListView from .proposal_views import ProposalListView @@ -25,4 +26,5 @@ path("proposals/", ProposalListView.as_view(), name="proposal-list"), path("projects/", ProjectListView.as_view(), name="project-list"), path("invoices/", InvoiceListView.as_view(), name="invoice-list"), + path("fiscal-configuration/", FiscalConfigurationView.as_view(), name="fiscal-configuration"), ] diff --git a/backend/fiscal/services.py b/backend/fiscal/services.py index 440700b..a567458 100644 --- a/backend/fiscal/services.py +++ b/backend/fiscal/services.py @@ -13,6 +13,10 @@ class FiscalAccessDenied(PermissionError): """Raised when a caller lacks Fiscal Configuration business access.""" +class FiscalConfigurationNotConfigured(LookupError): + """Raised when an authorized workspace has no fiscal configuration.""" + + def _authorize(context: ActiveWorkspaceContext) -> Workspace: try: membership = Membership.objects.get(pk=context.membership.pk, workspace=context.workspace) @@ -67,4 +71,6 @@ def get_current_fiscal_configuration(context: ActiveWorkspaceContext) -> FiscalC try: return FiscalConfiguration.objects.for_workspace(workspace).latest("version") except FiscalConfiguration.DoesNotExist as error: - raise FiscalAccessDenied("No fiscal configuration exists in the active workspace.") from error + raise FiscalConfigurationNotConfigured( + "No fiscal configuration exists in the active workspace." + ) from error