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.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- 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])
|