nat-engine 1__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.
Files changed (299) hide show
  1. mannf/__init__.py +33 -0
  2. mannf/__main__.py +10 -0
  3. mannf/_version.py +8 -0
  4. mannf/agents/__init__.py +7 -0
  5. mannf/agents/analyzer_agent.py +9 -0
  6. mannf/agents/base.py +9 -0
  7. mannf/agents/bdi_agent.py +9 -0
  8. mannf/agents/belief_state.py +9 -0
  9. mannf/agents/coordinator_agent.py +9 -0
  10. mannf/agents/executor_agent.py +9 -0
  11. mannf/agents/monitor_agent.py +9 -0
  12. mannf/agents/oracle_agent.py +9 -0
  13. mannf/agents/planner_agent.py +9 -0
  14. mannf/agents/test_agent.py +9 -0
  15. mannf/anomaly/__init__.py +7 -0
  16. mannf/anomaly/enhanced_detector.py +9 -0
  17. mannf/cli.py +9 -0
  18. mannf/core/__init__.py +26 -0
  19. mannf/core/agents/__init__.py +52 -0
  20. mannf/core/agents/accessibility_scanner_agent.py +245 -0
  21. mannf/core/agents/analyzer_agent.py +224 -0
  22. mannf/core/agents/autonomous_loop_agent.py +1086 -0
  23. mannf/core/agents/autonomous_loop_models.py +62 -0
  24. mannf/core/agents/autonomous_run_differ.py +427 -0
  25. mannf/core/agents/base.py +128 -0
  26. mannf/core/agents/bdi_agent.py +330 -0
  27. mannf/core/agents/belief_state.py +202 -0
  28. mannf/core/agents/browser_coordinator_agent.py +224 -0
  29. mannf/core/agents/browser_executor_agent.py +410 -0
  30. mannf/core/agents/coordinator_agent.py +262 -0
  31. mannf/core/agents/executor_agent.py +222 -0
  32. mannf/core/agents/monitor_agent.py +188 -0
  33. mannf/core/agents/oracle_agent.py +150 -0
  34. mannf/core/agents/performance_testing_agent.py +279 -0
  35. mannf/core/agents/planner_agent.py +128 -0
  36. mannf/core/agents/test_agent.py +249 -0
  37. mannf/core/agents/visual_regression_agent.py +311 -0
  38. mannf/core/agents/web_crawler_agent.py +510 -0
  39. mannf/core/agents/worker_pool.py +366 -0
  40. mannf/core/anomaly/__init__.py +14 -0
  41. mannf/core/anomaly/enhanced_detector.py +541 -0
  42. mannf/core/browser/__init__.py +63 -0
  43. mannf/core/browser/accessibility_scanner.py +424 -0
  44. mannf/core/browser/discovery_model.py +178 -0
  45. mannf/core/browser/dom_snapshot.py +349 -0
  46. mannf/core/browser/ingestor_bridge.py +371 -0
  47. mannf/core/browser/performance_metrics.py +217 -0
  48. mannf/core/browser/reflection_analyzer.py +442 -0
  49. mannf/core/browser/scenario_generator.py +1100 -0
  50. mannf/core/browser/security_scenario_generator.py +695 -0
  51. mannf/core/browser/visual_comparer.py +159 -0
  52. mannf/core/diagnostics/__init__.py +28 -0
  53. mannf/core/diagnostics/failure_clusterer.py +211 -0
  54. mannf/core/diagnostics/flake_detector.py +233 -0
  55. mannf/core/diagnostics/root_cause_analyzer.py +273 -0
  56. mannf/core/distributed/__init__.py +16 -0
  57. mannf/core/distributed/endpoint.py +139 -0
  58. mannf/core/distributed/system_under_test.py +207 -0
  59. mannf/core/functional_orchestrator.py +428 -0
  60. mannf/core/messaging/__init__.py +11 -0
  61. mannf/core/messaging/bus.py +113 -0
  62. mannf/core/messaging/messages.py +89 -0
  63. mannf/core/nat_orchestrator.py +342 -0
  64. mannf/core/neural/__init__.py +183 -0
  65. mannf/core/orchestrator.py +272 -0
  66. mannf/core/prioritization/__init__.py +17 -0
  67. mannf/core/prioritization/adaptive_controller.py +509 -0
  68. mannf/core/prioritization/belief_prioritizer.py +231 -0
  69. mannf/core/prioritization/risk_scorer.py +430 -0
  70. mannf/core/reporting/__init__.py +12 -0
  71. mannf/core/reporting/unified_report.py +664 -0
  72. mannf/core/testing/__init__.py +17 -0
  73. mannf/core/testing/adaptive_controller.py +149 -0
  74. mannf/core/testing/models.py +179 -0
  75. mannf/core/validation/__init__.py +10 -0
  76. mannf/core/validation/self_validation_runner.py +180 -0
  77. mannf/dashboard/__init__.py +7 -0
  78. mannf/dashboard/app.py +9 -0
  79. mannf/dashboard/models.py +9 -0
  80. mannf/dashboard/static/index.html +2538 -0
  81. mannf/dashboard/telemetry.py +9 -0
  82. mannf/distributed/__init__.py +7 -0
  83. mannf/distributed/endpoint.py +9 -0
  84. mannf/distributed/system_under_test.py +9 -0
  85. mannf/healing/__init__.py +7 -0
  86. mannf/healing/graphql_schema_diff.py +9 -0
  87. mannf/healing/healer.py +9 -0
  88. mannf/healing/models.py +9 -0
  89. mannf/healing/schema_diff.py +9 -0
  90. mannf/integrations/__init__.py +7 -0
  91. mannf/integrations/auth.py +9 -0
  92. mannf/integrations/graphql_parser.py +9 -0
  93. mannf/integrations/graphql_sut.py +9 -0
  94. mannf/integrations/http_sut.py +9 -0
  95. mannf/integrations/openapi_parser.py +9 -0
  96. mannf/integrations/postman_parser.py +9 -0
  97. mannf/llm/__init__.py +7 -0
  98. mannf/llm/anthropic_provider.py +9 -0
  99. mannf/llm/base.py +9 -0
  100. mannf/llm/config.py +9 -0
  101. mannf/llm/factory.py +9 -0
  102. mannf/llm/openai_provider.py +9 -0
  103. mannf/llm/prompts.py +9 -0
  104. mannf/messaging/__init__.py +7 -0
  105. mannf/messaging/bus.py +9 -0
  106. mannf/messaging/messages.py +9 -0
  107. mannf/nat_orchestrator.py +9 -0
  108. mannf/neural/__init__.py +7 -0
  109. mannf/orchestrator.py +9 -0
  110. mannf/prioritization/__init__.py +7 -0
  111. mannf/prioritization/adaptive_controller.py +9 -0
  112. mannf/prioritization/belief_prioritizer.py +9 -0
  113. mannf/prioritization/risk_scorer.py +9 -0
  114. mannf/product/__init__.py +29 -0
  115. mannf/product/admin/__init__.py +3 -0
  116. mannf/product/admin/routes.py +514 -0
  117. mannf/product/auth/__init__.py +5 -0
  118. mannf/product/auth/saml.py +212 -0
  119. mannf/product/billing/__init__.py +5 -0
  120. mannf/product/billing/audit.py +160 -0
  121. mannf/product/billing/feature_gates.py +180 -0
  122. mannf/product/billing/metering.py +179 -0
  123. mannf/product/billing/notifications.py +181 -0
  124. mannf/product/billing/plans.py +133 -0
  125. mannf/product/billing/rate_limits.py +35 -0
  126. mannf/product/billing/stripe_billing.py +906 -0
  127. mannf/product/billing/tenant_auth.py +233 -0
  128. mannf/product/billing/tenant_manager.py +873 -0
  129. mannf/product/cli.py +3900 -0
  130. mannf/product/cli_admin.py +408 -0
  131. mannf/product/dashboard/__init__.py +61 -0
  132. mannf/product/dashboard/app.py +3567 -0
  133. mannf/product/dashboard/models.py +460 -0
  134. mannf/product/dashboard/static/index.html +6347 -0
  135. mannf/product/dashboard/static/manifest.json +25 -0
  136. mannf/product/dashboard/static/pwa-icon-192.png +0 -0
  137. mannf/product/dashboard/static/pwa-icon-512.png +0 -0
  138. mannf/product/dashboard/static/sw.js +64 -0
  139. mannf/product/dashboard/telemetry.py +547 -0
  140. mannf/product/database.py +145 -0
  141. mannf/product/demo.py +844 -0
  142. mannf/product/doctor.py +509 -0
  143. mannf/product/exporters/__init__.py +65 -0
  144. mannf/product/exporters/azuredevops_exporter.py +257 -0
  145. mannf/product/exporters/base.py +307 -0
  146. mannf/product/exporters/bugzilla_exporter.py +200 -0
  147. mannf/product/exporters/dedup.py +275 -0
  148. mannf/product/exporters/finding_adapter.py +216 -0
  149. mannf/product/exporters/github_exporter.py +197 -0
  150. mannf/product/exporters/gitlab_exporter.py +215 -0
  151. mannf/product/exporters/jira_exporter.py +180 -0
  152. mannf/product/exporters/linear_exporter.py +195 -0
  153. mannf/product/exporters/loader.py +233 -0
  154. mannf/product/exporters/pagerduty_exporter.py +363 -0
  155. mannf/product/exporters/sentry_exporter.py +322 -0
  156. mannf/product/exporters/servicenow_exporter.py +240 -0
  157. mannf/product/exporters/shortcut_exporter.py +231 -0
  158. mannf/product/exporters/webhook_exporter.py +383 -0
  159. mannf/product/formatters/__init__.py +18 -0
  160. mannf/product/formatters/allure_formatter.py +161 -0
  161. mannf/product/formatters/ctrf_formatter.py +149 -0
  162. mannf/product/healing/__init__.py +30 -0
  163. mannf/product/healing/graphql_schema_diff.py +152 -0
  164. mannf/product/healing/healer.py +141 -0
  165. mannf/product/healing/models.py +175 -0
  166. mannf/product/healing/schema_diff.py +251 -0
  167. mannf/product/ingestors/__init__.py +77 -0
  168. mannf/product/ingestors/base.py +256 -0
  169. mannf/product/ingestors/bgstm_ingestor.py +764 -0
  170. mannf/product/ingestors/curl_ingestor.py +1019 -0
  171. mannf/product/ingestors/cypress_ingestor.py +487 -0
  172. mannf/product/ingestors/gherkin_ingestor.py +967 -0
  173. mannf/product/ingestors/graphql_ingestor.py +845 -0
  174. mannf/product/ingestors/grpc_ingestor.py +591 -0
  175. mannf/product/ingestors/har_ingestor.py +976 -0
  176. mannf/product/ingestors/loader.py +284 -0
  177. mannf/product/ingestors/models.py +146 -0
  178. mannf/product/ingestors/openapi_ingestor.py +606 -0
  179. mannf/product/ingestors/playwright_ingestor.py +449 -0
  180. mannf/product/ingestors/postman_ingestor.py +631 -0
  181. mannf/product/ingestors/traffic_ingestor.py +679 -0
  182. mannf/product/ingestors/websocket_ingestor.py +526 -0
  183. mannf/product/integrations/__init__.py +21 -0
  184. mannf/product/integrations/auth.py +190 -0
  185. mannf/product/integrations/graphql_parser.py +436 -0
  186. mannf/product/integrations/graphql_sut.py +247 -0
  187. mannf/product/integrations/grpc_sut.py +469 -0
  188. mannf/product/integrations/http_sut.py +237 -0
  189. mannf/product/integrations/kafka_adapter.py +342 -0
  190. mannf/product/integrations/openapi_parser.py +513 -0
  191. mannf/product/integrations/postman_parser.py +467 -0
  192. mannf/product/integrations/webhook_receiver.py +344 -0
  193. mannf/product/integrations/websocket_sut.py +434 -0
  194. mannf/product/llm/__init__.py +25 -0
  195. mannf/product/llm/anthropic_provider.py +94 -0
  196. mannf/product/llm/base.py +267 -0
  197. mannf/product/llm/config.py +48 -0
  198. mannf/product/llm/factory.py +42 -0
  199. mannf/product/llm/openai_provider.py +93 -0
  200. mannf/product/llm/prompts.py +403 -0
  201. mannf/product/llm/root_cause_service.py +311 -0
  202. mannf/product/llm/test_plan_models.py +78 -0
  203. mannf/product/metrics.py +149 -0
  204. mannf/product/middleware/__init__.py +3 -0
  205. mannf/product/middleware/audit_middleware.py +112 -0
  206. mannf/product/middleware/tenant_isolation.py +114 -0
  207. mannf/product/models.py +347 -0
  208. mannf/product/notifications/__init__.py +24 -0
  209. mannf/product/notifications/dispatcher.py +411 -0
  210. mannf/product/onboarding.py +190 -0
  211. mannf/product/orchestration/__init__.py +39 -0
  212. mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
  213. mannf/product/orchestration/pipeline.py +401 -0
  214. mannf/product/orchestrator.py +987 -0
  215. mannf/product/orchestrator_models.py +269 -0
  216. mannf/product/regression/__init__.py +36 -0
  217. mannf/product/regression/differ.py +172 -0
  218. mannf/product/regression/masking.py +100 -0
  219. mannf/product/regression/models.py +232 -0
  220. mannf/product/regression/recorder.py +124 -0
  221. mannf/product/regression/replayer.py +168 -0
  222. mannf/product/reports/__init__.py +10 -0
  223. mannf/product/reports/pdf.py +132 -0
  224. mannf/product/scheduling/__init__.py +57 -0
  225. mannf/product/scheduling/cron_utils.py +251 -0
  226. mannf/product/scheduling/engine.py +473 -0
  227. mannf/product/scheduling/models.py +86 -0
  228. mannf/product/scheduling/queue.py +894 -0
  229. mannf/product/scheduling/store.py +235 -0
  230. mannf/product/security/__init__.py +21 -0
  231. mannf/product/security/belief_guided.py +143 -0
  232. mannf/product/security/checks/__init__.py +55 -0
  233. mannf/product/security/checks/base.py +69 -0
  234. mannf/product/security/checks/bfla.py +77 -0
  235. mannf/product/security/checks/bola.py +77 -0
  236. mannf/product/security/checks/bopla.py +80 -0
  237. mannf/product/security/checks/broken_auth.py +86 -0
  238. mannf/product/security/checks/graphql_security.py +299 -0
  239. mannf/product/security/checks/inventory.py +70 -0
  240. mannf/product/security/checks/misconfig.py +158 -0
  241. mannf/product/security/checks/resource_consumption.py +70 -0
  242. mannf/product/security/checks/sensitive_flows.py +80 -0
  243. mannf/product/security/checks/ssrf.py +101 -0
  244. mannf/product/security/checks/unsafe_consumption.py +120 -0
  245. mannf/product/security/models.py +92 -0
  246. mannf/product/security/plugin_loader.py +182 -0
  247. mannf/product/security/reporter.py +92 -0
  248. mannf/product/security/scanner.py +183 -0
  249. mannf/product/server.py +6220 -0
  250. mannf/product/setup_wizard.py +873 -0
  251. mannf/product/status.py +404 -0
  252. mannf/product/storage/__init__.py +10 -0
  253. mannf/product/storage/artifact_store.py +343 -0
  254. mannf/product/telemetry.py +300 -0
  255. mannf/product/uninstall.py +169 -0
  256. mannf/product/upgrade.py +139 -0
  257. mannf/product/weights/__init__.py +13 -0
  258. mannf/product/weights/blob_store.py +299 -0
  259. mannf/product/weights/factory.py +42 -0
  260. mannf/product/weights/registry.py +159 -0
  261. mannf/product/weights/store.py +210 -0
  262. mannf/regression/__init__.py +7 -0
  263. mannf/regression/differ.py +9 -0
  264. mannf/regression/masking.py +9 -0
  265. mannf/regression/models.py +9 -0
  266. mannf/regression/recorder.py +9 -0
  267. mannf/regression/replayer.py +9 -0
  268. mannf/security/__init__.py +7 -0
  269. mannf/security/belief_guided.py +9 -0
  270. mannf/security/checks/__init__.py +7 -0
  271. mannf/security/checks/base.py +9 -0
  272. mannf/security/checks/bfla.py +9 -0
  273. mannf/security/checks/bola.py +9 -0
  274. mannf/security/checks/bopla.py +9 -0
  275. mannf/security/checks/broken_auth.py +9 -0
  276. mannf/security/checks/graphql_security.py +9 -0
  277. mannf/security/checks/inventory.py +9 -0
  278. mannf/security/checks/misconfig.py +9 -0
  279. mannf/security/checks/resource_consumption.py +9 -0
  280. mannf/security/checks/sensitive_flows.py +9 -0
  281. mannf/security/checks/ssrf.py +9 -0
  282. mannf/security/checks/unsafe_consumption.py +9 -0
  283. mannf/security/models.py +9 -0
  284. mannf/security/reporter.py +9 -0
  285. mannf/security/scanner.py +9 -0
  286. mannf/server.py +9 -0
  287. mannf/testing/__init__.py +7 -0
  288. mannf/testing/adaptive_controller.py +9 -0
  289. mannf/testing/models.py +9 -0
  290. mannf/weights/__init__.py +7 -0
  291. mannf/weights/registry.py +9 -0
  292. mannf/weights/store.py +9 -0
  293. nat_engine-1.dist-info/METADATA +555 -0
  294. nat_engine-1.dist-info/RECORD +299 -0
  295. nat_engine-1.dist-info/WHEEL +5 -0
  296. nat_engine-1.dist-info/entry_points.txt +4 -0
  297. nat_engine-1.dist-info/licenses/LICENSE +651 -0
  298. nat_engine-1.dist-info/licenses/NOTICE +178 -0
  299. nat_engine-1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,179 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+
5
+ """Metered usage reporting for NAT SaaS module add-ons.
6
+
7
+ For plans with metered billing, ``report_module_usage`` creates a usage record
8
+ on the appropriate Stripe subscription item. For flat-rate plans the function
9
+ is a no-op but still logs usage internally for tracking purposes.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def report_module_usage(tenant_id: str, module_name: str, quantity: int = 1) -> None:
20
+ """Report metered usage for a module add-on to Stripe.
21
+
22
+ For plans with metered billing, this creates a usage record on the
23
+ appropriate subscription item. For flat-rate plans, this is a no-op
24
+ but still logs for internal tracking.
25
+
26
+ Args:
27
+ tenant_id: The NAT tenant UUID string.
28
+ module_name: The scan module being used (e.g. ``"visual"``,
29
+ ``"performance"``).
30
+ quantity: Number of usage units to report. Defaults to ``1``.
31
+ """
32
+ logger.debug(
33
+ "report_module_usage: tenant=%s module=%s quantity=%d",
34
+ tenant_id,
35
+ module_name,
36
+ quantity,
37
+ )
38
+
39
+ try:
40
+ _report_to_stripe(tenant_id, module_name, quantity)
41
+ except Exception as exc: # noqa: BLE001
42
+ # Non-fatal: log and continue. Stripe usage reporting should never
43
+ # block the scan from proceeding.
44
+ logger.warning(
45
+ "Failed to report module usage to Stripe (tenant=%s, module=%s): %s",
46
+ tenant_id,
47
+ module_name,
48
+ exc,
49
+ )
50
+
51
+
52
+ def _report_to_stripe(tenant_id: str, module_name: str, quantity: int) -> None:
53
+ """Internal: attempt to create a Stripe usage record for the module.
54
+
55
+ Looks up the tenant's subscription item ID for the given module product,
56
+ then creates a usage record via the Stripe API. Silently skips if:
57
+ - Stripe is not configured (``STRIPE_SECRET_KEY`` absent)
58
+ - No module product ID is configured for *module_name*
59
+ - The tenant has no subscription item matching the module product
60
+ - The database is unavailable
61
+ """
62
+ import os # noqa: PLC0415
63
+
64
+ stripe_key = os.environ.get("STRIPE_SECRET_KEY", "")
65
+ if not stripe_key:
66
+ logger.debug("STRIPE_SECRET_KEY not set — skipping Stripe usage report")
67
+ return
68
+
69
+ from mannf.product.billing.plans import MODULE_PRODUCTS # noqa: PLC0415
70
+
71
+ module_product_id = MODULE_PRODUCTS.get(module_name)
72
+ if not module_product_id:
73
+ logger.debug(
74
+ "No Stripe product configured for module '%s' — skipping usage report",
75
+ module_name,
76
+ )
77
+ return
78
+
79
+ # Attempt to resolve the subscription item ID from the tenant record.
80
+ subscription_item_id = _resolve_subscription_item_id(tenant_id, module_product_id)
81
+ if not subscription_item_id:
82
+ logger.debug(
83
+ "No subscription item found for tenant=%s module=%s — skipping usage report",
84
+ tenant_id,
85
+ module_name,
86
+ )
87
+ return
88
+
89
+ import stripe # noqa: PLC0415
90
+
91
+ stripe.api_key = stripe_key
92
+ stripe.SubscriptionItem.create_usage_record(
93
+ subscription_item_id,
94
+ quantity=quantity,
95
+ action="increment",
96
+ )
97
+ logger.info(
98
+ "Reported %d usage unit(s) for tenant=%s module=%s (item=%s)",
99
+ quantity,
100
+ tenant_id,
101
+ module_name,
102
+ subscription_item_id,
103
+ )
104
+
105
+
106
+ def _resolve_subscription_item_id(tenant_id: str, product_id: str) -> str | None:
107
+ """Return the Stripe subscription item ID for *product_id* on *tenant_id*.
108
+
109
+ Queries the Stripe API for the tenant's subscription items and returns the
110
+ ID of the item whose price is for the given product. Returns ``None`` if
111
+ not found or if the database / Stripe is unavailable.
112
+ """
113
+ import os # noqa: PLC0415
114
+
115
+ stripe_key = os.environ.get("STRIPE_SECRET_KEY", "")
116
+ if not stripe_key:
117
+ return None
118
+
119
+ subscription_id = _get_tenant_subscription_id(tenant_id)
120
+ if not subscription_id:
121
+ return None
122
+
123
+ try:
124
+ import stripe # noqa: PLC0415
125
+
126
+ stripe.api_key = stripe_key
127
+ subscription = stripe.Subscription.retrieve(subscription_id, expand=["items.data.price.product"])
128
+ for item in subscription.get("items", {}).get("data", []):
129
+ item_product = item.get("price", {}).get("product")
130
+ item_product_id = (
131
+ item_product.get("id") if isinstance(item_product, dict) else item_product
132
+ )
133
+ if item_product_id == product_id:
134
+ return item["id"]
135
+ except Exception as exc: # noqa: BLE001
136
+ logger.debug("_resolve_subscription_item_id failed: %s", exc)
137
+
138
+ return None
139
+
140
+
141
+ def _get_tenant_subscription_id(tenant_id: str) -> str | None:
142
+ """Return the Stripe subscription ID for *tenant_id* from the database."""
143
+ try:
144
+ import asyncio # noqa: PLC0415
145
+ from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
146
+
147
+ if _build_engine() is None or _async_session_factory is None:
148
+ return None
149
+
150
+ import uuid as _uuid # noqa: PLC0415
151
+
152
+ async def _fetch() -> str | None:
153
+ from sqlalchemy import select # noqa: PLC0415
154
+ from mannf.product.models import Tenant # noqa: PLC0415
155
+
156
+ try:
157
+ tenant_uuid = _uuid.UUID(tenant_id)
158
+ except ValueError:
159
+ return None
160
+
161
+ async with _async_session_factory() as session:
162
+ result = await session.execute(
163
+ select(Tenant.stripe_subscription_id).where(Tenant.id == tenant_uuid)
164
+ )
165
+ return result.scalar_one_or_none()
166
+
167
+ try:
168
+ loop = asyncio.get_running_loop()
169
+ # Inside an async context — use a future
170
+ import concurrent.futures # noqa: PLC0415
171
+
172
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
173
+ future = pool.submit(asyncio.run, _fetch())
174
+ return future.result(timeout=5)
175
+ except RuntimeError:
176
+ return asyncio.run(_fetch())
177
+ except Exception as exc: # noqa: BLE001
178
+ logger.debug("_get_tenant_subscription_id failed: %s", exc)
179
+ return None
@@ -0,0 +1,181 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+
5
+ """Billing event notification dispatcher.
6
+
7
+ Fires on key billing lifecycle events and delivers notifications via:
8
+ - **Webhook**: HTTP POST to a tenant-configured URL (Team/Enterprise plans).
9
+ - **Email** (log-based placeholder): structured log entry for future
10
+ email-service integration.
11
+
12
+ Usage::
13
+
14
+ from mannf.product.billing.notifications import dispatch_billing_notification
15
+
16
+ await dispatch_billing_notification(
17
+ event_type="payment.failed",
18
+ tenant_id="<uuid>",
19
+ tenant_email="user@example.com",
20
+ details={"stripe_invoice_id": "in_…"},
21
+ )
22
+
23
+ All errors are caught and logged — notifications must never block billing flows.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import json
30
+ import logging
31
+ import os
32
+ from typing import Any, Optional
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Public dispatcher
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ async def dispatch_billing_notification(
42
+ event_type: str,
43
+ *,
44
+ tenant_id: Optional[str] = None,
45
+ tenant_email: Optional[str] = None,
46
+ webhook_url: Optional[str] = None,
47
+ details: Optional[dict[str, Any]] = None,
48
+ ) -> None:
49
+ """Dispatch a billing notification for the given event type.
50
+
51
+ Delivers the notification to all configured channels (webhook, email)
52
+ concurrently. All failures are swallowed and logged.
53
+
54
+ Args:
55
+ event_type: Billing event identifier, e.g. ``"payment.failed"``.
56
+ tenant_id: UUID string of the affected tenant.
57
+ tenant_email: Tenant's email address (used for email notifications).
58
+ webhook_url: Tenant-configured webhook URL (Team/Enterprise plans).
59
+ If ``None``, the global ``NAT_BILLING_WEBHOOK_URL`` env var is
60
+ checked as a fallback.
61
+ details: Additional event context to include in the notification.
62
+ """
63
+ payload: dict[str, Any] = {
64
+ "event_type": event_type,
65
+ "tenant_id": tenant_id,
66
+ "details": details or {},
67
+ }
68
+
69
+ tasks = []
70
+
71
+ # Resolve webhook URL: per-tenant override → global env fallback
72
+ effective_webhook_url = webhook_url or os.environ.get(
73
+ "NAT_BILLING_WEBHOOK_URL", ""
74
+ )
75
+ if effective_webhook_url:
76
+ tasks.append(_deliver_webhook(effective_webhook_url, payload))
77
+
78
+ # Email notification (log-based placeholder)
79
+ if tenant_email:
80
+ tasks.append(_deliver_email(event_type, tenant_email, payload))
81
+
82
+ if tasks:
83
+ results = await asyncio.gather(*tasks, return_exceptions=True)
84
+ for i, result in enumerate(results):
85
+ if isinstance(result, Exception):
86
+ logger.warning(
87
+ "Billing notification delivery failed (task %d) for event %s: %s",
88
+ i,
89
+ event_type,
90
+ result,
91
+ )
92
+ else:
93
+ logger.debug(
94
+ "No notification channels configured for billing event: %s", event_type
95
+ )
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Delivery backends
100
+ # ---------------------------------------------------------------------------
101
+
102
+
103
+ async def _deliver_webhook(
104
+ url: str, payload: dict[str, Any], *, timeout: float = 10.0
105
+ ) -> None:
106
+ """POST the billing event payload to the configured webhook URL.
107
+
108
+ Args:
109
+ url: Target webhook endpoint.
110
+ payload: JSON-serialisable event payload.
111
+ timeout: Request timeout in seconds.
112
+ """
113
+ try:
114
+ import aiohttp # noqa: PLC0415
115
+ except ImportError:
116
+ logger.warning(
117
+ "_deliver_webhook: aiohttp not installed — webhook delivery skipped"
118
+ )
119
+ return
120
+
121
+ try:
122
+ body = json.dumps(payload, default=str)
123
+ headers = {
124
+ "Content-Type": "application/json",
125
+ "User-Agent": "NAT-Billing/1.0",
126
+ }
127
+
128
+ # Include optional signing secret
129
+ signing_secret = os.environ.get("NAT_BILLING_WEBHOOK_SECRET", "")
130
+ if signing_secret:
131
+ import hashlib # noqa: PLC0415
132
+ import hmac # noqa: PLC0415
133
+
134
+ sig = hmac.new(
135
+ signing_secret.encode(), body.encode(), hashlib.sha256
136
+ ).hexdigest()
137
+ headers["X-NAT-Signature"] = f"sha256={sig}"
138
+
139
+ async with aiohttp.ClientSession() as session:
140
+ async with session.post(
141
+ url, data=body, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout)
142
+ ) as resp:
143
+ if resp.status >= 400:
144
+ logger.warning(
145
+ "_deliver_webhook: server returned %d for event %s",
146
+ resp.status,
147
+ payload.get("event_type"),
148
+ )
149
+ else:
150
+ logger.debug(
151
+ "_deliver_webhook: delivered %s to %s (status=%d)",
152
+ payload.get("event_type"),
153
+ url,
154
+ resp.status,
155
+ )
156
+ except Exception as exc: # noqa: BLE001
157
+ raise RuntimeError(
158
+ f"Webhook delivery to {url!r} failed: {exc}"
159
+ ) from exc
160
+
161
+
162
+ async def _deliver_email(
163
+ event_type: str, recipient: str, payload: dict[str, Any]
164
+ ) -> None:
165
+ """Log a structured email notification placeholder.
166
+
167
+ In future this will call an email service (SendGrid, SES, etc.). For now
168
+ it emits a structured INFO log entry that can be forwarded by any log
169
+ aggregator.
170
+
171
+ Args:
172
+ event_type: Billing event type.
173
+ recipient: Destination email address.
174
+ payload: Full event payload for context.
175
+ """
176
+ logger.info(
177
+ "BILLING_EMAIL_NOTIFICATION event_type=%s recipient=%s payload=%s",
178
+ event_type,
179
+ recipient,
180
+ json.dumps(payload, default=str),
181
+ )
@@ -0,0 +1,133 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+
5
+ """NAT SaaS plan tier definitions."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class PlanTier:
15
+ name: str
16
+ display_name: str
17
+ monthly_scan_quota: int # functional scans per month
18
+ monthly_security_scan_quota: int # security scans per month
19
+ price_monthly_cents: int # price in cents (0 = free)
20
+ stripe_price_id: str | None # recurring price ID; populated from env vars
21
+ stripe_product_id: str | None # product ID; populated from env vars
22
+ rate_limit_per_minute: int # API requests per minute for this plan
23
+ max_concurrent_scans: int # maximum simultaneous scans for this plan
24
+ included_modules: tuple[str, ...] = () # modules included in base subscription
25
+ max_scheduled_scans: int = 0 # max recurring scan schedules (0 = not available)
26
+ max_seats: int = 0 # max user seats (0 = unlimited)
27
+
28
+
29
+ PLAN_TIERS: dict[str, PlanTier] = {
30
+ "free": PlanTier(
31
+ name="free",
32
+ display_name="Free",
33
+ monthly_scan_quota=50,
34
+ monthly_security_scan_quota=10,
35
+ price_monthly_cents=0,
36
+ stripe_price_id=os.environ.get("STRIPE_PRICE_ID_FREE") or None,
37
+ stripe_product_id=os.environ.get("STRIPE_PRODUCT_ID_FREE") or None,
38
+ rate_limit_per_minute=10,
39
+ max_concurrent_scans=1,
40
+ included_modules=("functional",),
41
+ max_scheduled_scans=0,
42
+ max_seats=1,
43
+ ),
44
+ "pro": PlanTier(
45
+ name="pro",
46
+ display_name="Pro",
47
+ monthly_scan_quota=500,
48
+ monthly_security_scan_quota=100,
49
+ price_monthly_cents=4900, # $49/month
50
+ stripe_price_id=os.environ.get("STRIPE_PRICE_ID_PRO") or None,
51
+ stripe_product_id=os.environ.get("STRIPE_PRODUCT_ID_PRO") or None,
52
+ rate_limit_per_minute=60,
53
+ max_concurrent_scans=3,
54
+ included_modules=("functional", "visual", "accessibility"),
55
+ max_scheduled_scans=3,
56
+ max_seats=5,
57
+ ),
58
+ "team": PlanTier(
59
+ name="team",
60
+ display_name="Team",
61
+ monthly_scan_quota=2000,
62
+ monthly_security_scan_quota=500,
63
+ price_monthly_cents=14900, # $149/month
64
+ stripe_price_id=os.environ.get("STRIPE_PRICE_ID_TEAM") or None,
65
+ stripe_product_id=os.environ.get("STRIPE_PRODUCT_ID_TEAM") or None,
66
+ rate_limit_per_minute=200,
67
+ max_concurrent_scans=10,
68
+ included_modules=("functional", "visual", "accessibility", "performance"),
69
+ max_scheduled_scans=10,
70
+ max_seats=25,
71
+ ),
72
+ "enterprise": PlanTier(
73
+ name="enterprise",
74
+ display_name="Enterprise",
75
+ monthly_scan_quota=999999, # effectively unlimited
76
+ monthly_security_scan_quota=999999,
77
+ price_monthly_cents=0, # custom pricing
78
+ stripe_price_id=os.environ.get("STRIPE_PRICE_ID_ENTERPRISE") or None,
79
+ stripe_product_id=os.environ.get("STRIPE_PRODUCT_ID_ENTERPRISE") or None,
80
+ rate_limit_per_minute=1000,
81
+ max_concurrent_scans=999999, # effectively unlimited
82
+ included_modules=("functional", "visual", "accessibility", "performance"),
83
+ max_scheduled_scans=999_999,
84
+ max_seats=0,
85
+ ),
86
+ }
87
+
88
+ # Per-module Stripe product IDs for modular add-on pricing.
89
+ # Each key maps to the env var that holds the Stripe product ID for that module.
90
+ MODULE_PRODUCTS: dict[str, str | None] = {
91
+ "functional": os.environ.get("STRIPE_PRODUCT_ID_FUNCTIONAL") or None,
92
+ "visual": os.environ.get("STRIPE_PRODUCT_ID_VISUAL") or None,
93
+ "accessibility": os.environ.get("STRIPE_PRODUCT_ID_ACCESSIBILITY") or None,
94
+ "performance": os.environ.get("STRIPE_PRODUCT_ID_PERFORMANCE") or None,
95
+ "ai_intelligence": os.environ.get("STRIPE_PRODUCT_ID_AI_INTELLIGENCE") or None,
96
+ }
97
+
98
+ # Per-module Stripe price IDs for add-on billing.
99
+ MODULE_PRICES: dict[str, str | None] = {
100
+ "functional": os.environ.get("STRIPE_PRICE_ID_FUNCTIONAL") or None,
101
+ "visual": os.environ.get("STRIPE_PRICE_ID_VISUAL") or None,
102
+ "accessibility": os.environ.get("STRIPE_PRICE_ID_ACCESSIBILITY") or None,
103
+ "performance": os.environ.get("STRIPE_PRICE_ID_PERFORMANCE") or None,
104
+ }
105
+
106
+
107
+ def get_module_price_id(module_name: str) -> str | None:
108
+ """Return the Stripe price ID for a scan module add-on, or None."""
109
+ return MODULE_PRICES.get(module_name)
110
+
111
+
112
+ def get_plan(tier_name: str) -> PlanTier:
113
+ """Return the plan tier config, defaulting to 'free' for unknown tiers."""
114
+ return PLAN_TIERS.get(tier_name, PLAN_TIERS["free"])
115
+
116
+
117
+ def get_plan_by_stripe_price_id(price_id: str) -> PlanTier | None:
118
+ """Reverse-look up a plan by its Stripe price ID.
119
+
120
+ Useful in webhook handlers where only the price ID is known (e.g. from a
121
+ ``checkout.session.completed`` event).
122
+
123
+ Args:
124
+ price_id: The Stripe price ID to look up (e.g. ``"price_abc123"``).
125
+
126
+ Returns:
127
+ The matching :class:`PlanTier`, or ``None`` if no plan is configured
128
+ with that price ID.
129
+ """
130
+ for plan in PLAN_TIERS.values():
131
+ if plan.stripe_price_id and plan.stripe_price_id == price_id:
132
+ return plan
133
+ return None
@@ -0,0 +1,35 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+
5
+ """Per-plan rate limit configuration for the NAT SaaS API.
6
+
7
+ Each plan tier has its own request-per-minute and concurrent-scan limits
8
+ that override the global ``NAT_RATE_LIMIT`` environment variable when a
9
+ tenant is authenticated.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TypedDict
15
+
16
+
17
+ class RateLimitConfig(TypedDict):
18
+ requests_per_minute: int
19
+ concurrent_scans: int
20
+
21
+
22
+ PLAN_RATE_LIMITS: dict[str, RateLimitConfig] = {
23
+ "free": {"requests_per_minute": 30, "concurrent_scans": 1},
24
+ "pro": {"requests_per_minute": 120, "concurrent_scans": 3},
25
+ "team": {"requests_per_minute": 300, "concurrent_scans": 10},
26
+ "enterprise": {"requests_per_minute": 1000, "concurrent_scans": 50},
27
+ }
28
+
29
+ # Default to the free plan limits for unknown/unrecognised tiers.
30
+ _DEFAULT_PLAN = "free"
31
+
32
+
33
+ def get_rate_limit_config(plan_tier: str) -> RateLimitConfig:
34
+ """Return the rate-limit config for *plan_tier*, defaulting to free."""
35
+ return PLAN_RATE_LIMITS.get(plan_tier, PLAN_RATE_LIMITS[_DEFAULT_PLAN])