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,906 @@
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
+ """Stripe billing integration for NAT self-serve SaaS onboarding.
6
+
7
+ Provides:
8
+ - ``create_checkout_session`` — Stripe Checkout for plan sign-up
9
+ - ``create_portal_session`` — Stripe Customer Portal for subscription management
10
+ - ``handle_webhook_event`` — Process Stripe webhook events
11
+ - ``generate_api_key`` — Generate ``nat_pk_<32-hex>`` keys, hash for storage
12
+
13
+ All Stripe calls are gated on ``STRIPE_SECRET_KEY`` being set. If it is not
14
+ set the public helpers raise ``StripeNotConfiguredError`` which the server
15
+ layer translates into 503 Service Unavailable.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import logging
22
+ import os
23
+ import secrets
24
+ import uuid
25
+ from typing import Any
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Sentinel exception
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ class StripeNotConfiguredError(RuntimeError):
35
+ """Raised when STRIPE_SECRET_KEY is absent and a Stripe feature is requested."""
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Internal helpers
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ def _get_stripe():
44
+ """Return the ``stripe`` module, configured with the secret key.
45
+
46
+ Raises:
47
+ StripeNotConfiguredError: if ``STRIPE_SECRET_KEY`` is not set.
48
+ ImportError: if the ``stripe`` package is not installed.
49
+ """
50
+ secret_key = os.environ.get("STRIPE_SECRET_KEY", "")
51
+ if not secret_key:
52
+ raise StripeNotConfiguredError(
53
+ "STRIPE_SECRET_KEY is not set. Billing features are disabled."
54
+ )
55
+
56
+ import stripe # noqa: PLC0415
57
+
58
+ stripe.api_key = secret_key
59
+ return stripe
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # API key generation
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def generate_api_key() -> tuple[str, str, str]:
68
+ """Generate a new API key in ``nat_pk_<32-hex>`` format.
69
+
70
+ Returns:
71
+ A 3-tuple of ``(plaintext_key, key_hash, key_prefix)`` where:
72
+ - ``plaintext_key`` is the full key shown *once* to the customer.
73
+ - ``key_hash`` is the SHA-256 hex digest stored in the database.
74
+ - ``key_prefix`` is the first 8 characters of the plaintext key
75
+ (e.g. ``nat_pk_a``) used for display/identification.
76
+ """
77
+ random_hex = secrets.token_hex(32) # 64 hex chars — 32 bytes of entropy
78
+ plaintext_key = f"nat_pk_{random_hex}"
79
+ key_hash = hashlib.sha256(plaintext_key.encode()).hexdigest()
80
+ key_prefix = plaintext_key[:8]
81
+ return plaintext_key, key_hash, key_prefix
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Stripe Checkout
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ def create_checkout_session(
90
+ plan_tier: str,
91
+ success_url: str,
92
+ cancel_url: str,
93
+ module_addons: list[str] | None = None,
94
+ ) -> str:
95
+ """Create a Stripe Checkout Session for the given plan tier.
96
+
97
+ Args:
98
+ plan_tier: One of ``"pro"``, ``"team"``, or ``"enterprise"``.
99
+ success_url: URL Stripe redirects to after successful payment.
100
+ cancel_url: URL Stripe redirects to if the customer cancels.
101
+ module_addons: Optional list of scan module names to include as
102
+ add-on line items (e.g. ``["visual", "performance"]``).
103
+ Each module must have a configured
104
+ ``STRIPE_PRICE_ID_<MODULE>`` env var.
105
+
106
+ Returns:
107
+ The Stripe Checkout Session URL.
108
+
109
+ Raises:
110
+ StripeNotConfiguredError: if ``STRIPE_SECRET_KEY`` is not set.
111
+ ValueError: if the plan tier has no configured Stripe price ID, or if
112
+ a requested module add-on has no configured price ID.
113
+ """
114
+ from mannf.product.billing.plans import get_plan, get_module_price_id # noqa: PLC0415
115
+
116
+ stripe = _get_stripe()
117
+
118
+ plan = get_plan(plan_tier)
119
+ if not plan.stripe_price_id:
120
+ raise ValueError(
121
+ f"No Stripe price ID configured for plan '{plan_tier}'. "
122
+ f"Set the STRIPE_PRICE_ID_{plan_tier.upper()} environment variable."
123
+ )
124
+
125
+ line_items: list[dict] = [{"price": plan.stripe_price_id, "quantity": 1}]
126
+
127
+ addons = module_addons or []
128
+ for module in addons:
129
+ price_id = get_module_price_id(module)
130
+ if not price_id:
131
+ raise ValueError(
132
+ f"No Stripe price ID configured for module add-on '{module}'. "
133
+ f"Set the STRIPE_PRICE_ID_{module.upper()} environment variable."
134
+ )
135
+ line_items.append({"price": price_id, "quantity": 1})
136
+
137
+ session = stripe.checkout.Session.create(
138
+ mode="subscription",
139
+ line_items=line_items,
140
+ success_url=success_url,
141
+ cancel_url=cancel_url,
142
+ metadata={
143
+ "plan_tier": plan_tier,
144
+ "plan_name": plan.display_name,
145
+ "product_id": plan.stripe_product_id or "",
146
+ "module_addons": ",".join(addons),
147
+ },
148
+ )
149
+ return session.url
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Stripe Customer Portal
154
+ # ---------------------------------------------------------------------------
155
+
156
+
157
+ def create_portal_session(stripe_customer_id: str, return_url: str) -> str:
158
+ """Create a Stripe Customer Portal session for subscription management.
159
+
160
+ Args:
161
+ stripe_customer_id: The ``cus_…`` customer ID stored on the tenant.
162
+ return_url: URL to redirect to after the portal session ends.
163
+
164
+ Returns:
165
+ The Stripe Customer Portal session URL.
166
+
167
+ Raises:
168
+ StripeNotConfiguredError: if ``STRIPE_SECRET_KEY`` is not set.
169
+ """
170
+ stripe = _get_stripe()
171
+
172
+ session = stripe.billing_portal.Session.create(
173
+ customer=stripe_customer_id,
174
+ return_url=return_url,
175
+ )
176
+ return session.url
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Webhook handler
181
+ # ---------------------------------------------------------------------------
182
+
183
+
184
+ def handle_webhook_event(payload: bytes, sig_header: str) -> dict[str, Any]:
185
+ """Verify and dispatch a Stripe webhook event.
186
+
187
+ The function verifies the signature using ``STRIPE_WEBHOOK_SECRET``, then
188
+ handles the following event types:
189
+
190
+ - ``checkout.session.completed`` — creates a Tenant and ApiKey in the DB
191
+ - ``customer.subscription.updated`` — updates the tenant's plan tier
192
+ - ``customer.subscription.deleted`` — deactivates the tenant
193
+
194
+ Args:
195
+ payload: The raw request body bytes from Stripe.
196
+ sig_header: The value of the ``Stripe-Signature`` HTTP header.
197
+
198
+ Returns:
199
+ A dict describing the action taken, e.g.
200
+ ``{"action": "tenant_created", "tenant_id": "…"}``.
201
+
202
+ Raises:
203
+ StripeNotConfiguredError: if ``STRIPE_SECRET_KEY`` is not set.
204
+ stripe.error.SignatureVerificationError: if the signature is invalid.
205
+ """
206
+ stripe = _get_stripe()
207
+
208
+ webhook_secret = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
209
+ if not webhook_secret:
210
+ raise StripeNotConfiguredError(
211
+ "STRIPE_WEBHOOK_SECRET is not set. Webhook signature verification is disabled."
212
+ )
213
+
214
+ event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret)
215
+
216
+ event_type: str = event["type"]
217
+ event_data: dict = event["data"]["object"]
218
+
219
+ logger.info("Received Stripe webhook event: %s", event_type)
220
+
221
+ if event_type == "checkout.session.completed":
222
+ return _handle_checkout_completed(event_data)
223
+ elif event_type == "customer.subscription.updated":
224
+ return _handle_subscription_updated(event_data)
225
+ elif event_type == "customer.subscription.deleted":
226
+ return _handle_subscription_deleted(event_data)
227
+ elif event_type == "invoice.payment_failed":
228
+ return _handle_payment_failed(event_data)
229
+ elif event_type == "invoice.paid":
230
+ return _handle_invoice_paid(event_data)
231
+ elif event_type == "charge.refunded":
232
+ return _handle_charge_refunded(event_data)
233
+ else:
234
+ return {"action": "ignored", "event_type": event_type}
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # Private event handlers (synchronous DB writes via asyncio.run or direct)
239
+ # ---------------------------------------------------------------------------
240
+
241
+
242
+ def _handle_checkout_completed(session_data: dict) -> dict[str, Any]:
243
+ """Handle ``checkout.session.completed`` — provision Tenant + ApiKey.
244
+
245
+ Idempotent: if a Tenant with this ``stripe_customer_id`` already exists
246
+ (e.g. created by the sync endpoint), the plan and quotas are updated
247
+ instead of creating a duplicate.
248
+ """
249
+ import asyncio # noqa: PLC0415
250
+
251
+ customer_id: str = session_data.get("customer", "")
252
+ customer_email: str = session_data.get("customer_details", {}).get("email", "")
253
+ customer_name: str = session_data.get("customer_details", {}).get("name", "") or customer_email
254
+ subscription_id: str = session_data.get("subscription", "")
255
+ metadata: dict = session_data.get("metadata", {})
256
+ plan_tier: str = metadata.get("plan_tier", "free")
257
+ module_addons_raw: str = metadata.get("module_addons", "")
258
+ module_addons: list[str] = [m for m in module_addons_raw.split(",") if m]
259
+
260
+ plaintext_key, key_hash, key_prefix = generate_api_key()
261
+
262
+ coro = _upsert_tenant_from_checkout(
263
+ customer_id=customer_id,
264
+ customer_email=customer_email,
265
+ customer_name=customer_name,
266
+ subscription_id=subscription_id,
267
+ plan_tier=plan_tier,
268
+ key_hash=key_hash,
269
+ key_prefix=key_prefix,
270
+ module_addons=module_addons,
271
+ )
272
+ try:
273
+ asyncio.get_running_loop()
274
+ # Running inside an async context (e.g. FastAPI) — schedule as task
275
+ asyncio.ensure_future(coro)
276
+ asyncio.ensure_future(_log_checkout_event(customer_id, customer_email, plan_tier))
277
+ except RuntimeError:
278
+ # No running event loop — drive synchronously
279
+ try:
280
+ asyncio.run(coro)
281
+ except Exception as exc: # noqa: BLE001
282
+ logger.error("Failed to create tenant from checkout: %s", exc)
283
+
284
+ return {
285
+ "action": "tenant_created",
286
+ "customer_id": customer_id,
287
+ "plan_tier": plan_tier,
288
+ "api_key_prefix": key_prefix,
289
+ "plaintext_key": plaintext_key,
290
+ "module_addons": module_addons,
291
+ }
292
+
293
+
294
+ async def _log_checkout_event(customer_id: str, customer_email: str, plan_tier: str) -> None:
295
+ """Fire-and-forget: log a billing audit event for checkout completion."""
296
+ from mannf.product.billing.audit import log_billing_event # noqa: PLC0415
297
+
298
+ tenant_id = await _resolve_tenant_id_by_customer(customer_id)
299
+ await log_billing_event(
300
+ "tenant.created",
301
+ tenant_id=tenant_id,
302
+ source="stripe",
303
+ details={
304
+ "stripe_customer_id": customer_id,
305
+ "customer_email": customer_email,
306
+ "plan_tier": plan_tier,
307
+ },
308
+ )
309
+
310
+
311
+ def _handle_subscription_updated(subscription_data: dict) -> dict[str, Any]:
312
+ """Handle ``customer.subscription.updated`` — sync plan tier, quotas, and module add-ons."""
313
+ import asyncio # noqa: PLC0415
314
+
315
+ customer_id: str = subscription_data.get("customer", "")
316
+ subscription_id: str = subscription_data.get("id", "")
317
+ # Resolve plan tier from the first item's price metadata or product metadata
318
+ items = subscription_data.get("items", {}).get("data", [])
319
+ plan_tier = "free"
320
+ if items:
321
+ price_meta = items[0].get("price", {}).get("metadata", {})
322
+ plan_tier = price_meta.get("plan_tier", "free")
323
+
324
+ # Detect module add-ons by matching subscription item product IDs against
325
+ # MODULE_PRODUCTS. Items after the first are treated as add-on line items.
326
+ from mannf.product.billing.plans import MODULE_PRODUCTS # noqa: PLC0415
327
+
328
+ module_addons: list[str] = []
329
+ for item in items[1:]:
330
+ product_id = item.get("price", {}).get("product", "")
331
+ for module_name, module_product_id in MODULE_PRODUCTS.items():
332
+ if module_product_id and product_id == module_product_id:
333
+ module_addons.append(module_name)
334
+
335
+ logger.info(
336
+ "Subscription updated: customer=%s subscription=%s new_plan=%s module_addons=%s",
337
+ customer_id,
338
+ subscription_id,
339
+ plan_tier,
340
+ module_addons,
341
+ )
342
+
343
+ coro = _update_tenant_plan(
344
+ customer_id,
345
+ plan_tier,
346
+ subscription_id=subscription_id,
347
+ module_addons=module_addons,
348
+ )
349
+ try:
350
+ asyncio.get_running_loop()
351
+ asyncio.ensure_future(coro)
352
+ asyncio.ensure_future(_log_subscription_updated_event(customer_id, plan_tier, subscription_id))
353
+ except RuntimeError:
354
+ try:
355
+ asyncio.run(coro)
356
+ except Exception as exc: # noqa: BLE001
357
+ logger.error("Failed to update tenant plan: %s", exc)
358
+
359
+ return {
360
+ "action": "plan_updated",
361
+ "customer_id": customer_id,
362
+ "plan_tier": plan_tier,
363
+ "module_addons": module_addons,
364
+ }
365
+
366
+
367
+ async def _log_subscription_updated_event(
368
+ customer_id: str, plan_tier: str, subscription_id: str
369
+ ) -> None:
370
+ """Fire-and-forget: log a billing audit event for subscription update."""
371
+ from mannf.product.billing.audit import log_billing_event # noqa: PLC0415
372
+
373
+ tenant_id = await _resolve_tenant_id_by_customer(customer_id)
374
+ await log_billing_event(
375
+ "plan.changed",
376
+ tenant_id=tenant_id,
377
+ source="stripe",
378
+ details={
379
+ "stripe_customer_id": customer_id,
380
+ "stripe_subscription_id": subscription_id,
381
+ "plan_tier": plan_tier,
382
+ },
383
+ )
384
+
385
+
386
+ def _handle_subscription_deleted(subscription_data: dict) -> dict[str, Any]:
387
+ """Handle ``customer.subscription.deleted`` — deactivate tenant."""
388
+ import asyncio # noqa: PLC0415
389
+
390
+ customer_id: str = subscription_data.get("customer", "")
391
+
392
+ coro = _deactivate_tenant(customer_id)
393
+ try:
394
+ asyncio.get_running_loop()
395
+ asyncio.ensure_future(coro)
396
+ asyncio.ensure_future(_log_subscription_deleted_event(customer_id))
397
+ except RuntimeError:
398
+ try:
399
+ asyncio.run(coro)
400
+ except Exception as exc: # noqa: BLE001
401
+ logger.error("Failed to deactivate tenant: %s", exc)
402
+
403
+ return {
404
+ "action": "tenant_deactivated",
405
+ "customer_id": customer_id,
406
+ }
407
+
408
+
409
+ async def _log_subscription_deleted_event(customer_id: str) -> None:
410
+ """Fire-and-forget: log a billing audit event for subscription cancellation."""
411
+ from mannf.product.billing.audit import log_billing_event # noqa: PLC0415
412
+
413
+ tenant_id = await _resolve_tenant_id_by_customer(customer_id)
414
+ await log_billing_event(
415
+ "subscription.cancelled",
416
+ tenant_id=tenant_id,
417
+ source="stripe",
418
+ details={"stripe_customer_id": customer_id},
419
+ )
420
+
421
+
422
+ def _handle_payment_failed(invoice_data: dict) -> dict[str, Any]:
423
+ """Handle ``invoice.payment_failed`` — log the event for audit trail.
424
+
425
+ Does not immediately deactivate the tenant — Stripe handles retry
426
+ logic and sends ``customer.subscription.deleted`` after the grace
427
+ period expires.
428
+ """
429
+ import asyncio # noqa: PLC0415
430
+
431
+ customer_id: str = invoice_data.get("customer", "")
432
+ invoice_id: str = invoice_data.get("id", "")
433
+ attempt_count: int = invoice_data.get("attempt_count", 0)
434
+
435
+ logger.warning(
436
+ "Payment failed: customer=%s invoice=%s attempt=%d — grace period active",
437
+ customer_id,
438
+ invoice_id,
439
+ attempt_count,
440
+ )
441
+
442
+ try:
443
+ asyncio.get_running_loop()
444
+ asyncio.ensure_future(_log_payment_failed_event(customer_id, invoice_id, attempt_count))
445
+ except RuntimeError:
446
+ pass
447
+
448
+ return {
449
+ "action": "payment_failed_logged",
450
+ "customer_id": customer_id,
451
+ "invoice_id": invoice_id,
452
+ "attempt_count": attempt_count,
453
+ }
454
+
455
+
456
+ async def _log_payment_failed_event(
457
+ customer_id: str, invoice_id: str, attempt_count: int
458
+ ) -> None:
459
+ """Fire-and-forget: log a billing audit event for payment failure."""
460
+ from mannf.product.billing.audit import log_billing_event # noqa: PLC0415
461
+ from mannf.product.billing.notifications import dispatch_billing_notification # noqa: PLC0415
462
+
463
+ tenant_id = await _resolve_tenant_id_by_customer(customer_id)
464
+ await log_billing_event(
465
+ "payment.failed",
466
+ tenant_id=tenant_id,
467
+ source="stripe",
468
+ details={
469
+ "stripe_customer_id": customer_id,
470
+ "stripe_invoice_id": invoice_id,
471
+ "attempt_count": attempt_count,
472
+ },
473
+ )
474
+ await dispatch_billing_notification(
475
+ "payment.failed",
476
+ tenant_id=tenant_id,
477
+ details={
478
+ "stripe_customer_id": customer_id,
479
+ "stripe_invoice_id": invoice_id,
480
+ "attempt_count": attempt_count,
481
+ },
482
+ )
483
+
484
+
485
+ def _handle_invoice_paid(invoice_data: dict) -> dict[str, Any]:
486
+ """Handle ``invoice.paid`` — log successful payment."""
487
+ import asyncio # noqa: PLC0415
488
+
489
+ customer_id: str = invoice_data.get("customer", "")
490
+ invoice_id: str = invoice_data.get("id", "")
491
+ amount_paid: int = invoice_data.get("amount_paid", 0)
492
+
493
+ logger.info(
494
+ "Invoice paid: customer=%s invoice=%s amount=%d",
495
+ customer_id,
496
+ invoice_id,
497
+ amount_paid,
498
+ )
499
+
500
+ try:
501
+ asyncio.get_running_loop()
502
+ asyncio.ensure_future(_log_invoice_paid_event(customer_id, invoice_id, amount_paid))
503
+ except RuntimeError:
504
+ pass
505
+
506
+ return {
507
+ "action": "payment_succeeded_logged",
508
+ "customer_id": customer_id,
509
+ "invoice_id": invoice_id,
510
+ "amount_paid": amount_paid,
511
+ }
512
+
513
+
514
+ async def _log_invoice_paid_event(
515
+ customer_id: str, invoice_id: str, amount_paid: int
516
+ ) -> None:
517
+ """Fire-and-forget: log a billing audit event for successful payment."""
518
+ from mannf.product.billing.audit import log_billing_event # noqa: PLC0415
519
+
520
+ tenant_id = await _resolve_tenant_id_by_customer(customer_id)
521
+ await log_billing_event(
522
+ "payment.succeeded",
523
+ tenant_id=tenant_id,
524
+ source="stripe",
525
+ details={
526
+ "stripe_customer_id": customer_id,
527
+ "stripe_invoice_id": invoice_id,
528
+ "amount_paid_cents": amount_paid,
529
+ },
530
+ )
531
+
532
+
533
+ def _handle_charge_refunded(charge_data: dict) -> dict[str, Any]:
534
+ """Handle ``charge.refunded`` — log refund event."""
535
+ import asyncio # noqa: PLC0415
536
+
537
+ customer_id: str = charge_data.get("customer", "")
538
+ charge_id: str = charge_data.get("id", "")
539
+ amount_refunded: int = charge_data.get("amount_refunded", 0)
540
+
541
+ logger.info(
542
+ "Charge refunded: customer=%s charge=%s amount_refunded=%d",
543
+ customer_id,
544
+ charge_id,
545
+ amount_refunded,
546
+ )
547
+
548
+ try:
549
+ asyncio.get_running_loop()
550
+ asyncio.ensure_future(_log_charge_refunded_event(customer_id, charge_id, amount_refunded))
551
+ except RuntimeError:
552
+ pass
553
+
554
+ return {
555
+ "action": "charge_refunded_logged",
556
+ "customer_id": customer_id,
557
+ "charge_id": charge_id,
558
+ "amount_refunded": amount_refunded,
559
+ }
560
+
561
+
562
+ async def _log_charge_refunded_event(
563
+ customer_id: str, charge_id: str, amount_refunded: int
564
+ ) -> None:
565
+ """Fire-and-forget: log a billing audit event for a refund."""
566
+ from mannf.product.billing.audit import log_billing_event # noqa: PLC0415
567
+
568
+ tenant_id = await _resolve_tenant_id_by_customer(customer_id)
569
+ await log_billing_event(
570
+ "charge.refunded",
571
+ tenant_id=tenant_id,
572
+ source="stripe",
573
+ details={
574
+ "stripe_customer_id": customer_id,
575
+ "stripe_charge_id": charge_id,
576
+ "amount_refunded_cents": amount_refunded,
577
+ },
578
+ )
579
+
580
+
581
+ # ---------------------------------------------------------------------------
582
+ # Async DB helpers
583
+ # ---------------------------------------------------------------------------
584
+
585
+
586
+ async def _resolve_tenant_id_by_customer(customer_id: str) -> str | None:
587
+ """Return the tenant UUID string for a Stripe customer ID, or None."""
588
+ try:
589
+ from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
590
+
591
+ if _build_engine() is None or _async_session_factory is None:
592
+ return None
593
+
594
+ from sqlalchemy import select # noqa: PLC0415
595
+ from mannf.product.models import Tenant # noqa: PLC0415
596
+
597
+ async with _async_session_factory() as session:
598
+ stmt = select(Tenant.id).where(Tenant.stripe_customer_id == customer_id)
599
+ result = await session.execute(stmt)
600
+ row = result.scalar_one_or_none()
601
+ return str(row) if row else None
602
+ except Exception as exc: # noqa: BLE001
603
+ logger.debug("_resolve_tenant_id_by_customer failed: %s", exc)
604
+ return None
605
+
606
+
607
+ async def _create_tenant_and_key(
608
+ *,
609
+ customer_id: str,
610
+ customer_email: str,
611
+ customer_name: str,
612
+ subscription_id: str,
613
+ plan_tier: str,
614
+ key_hash: str,
615
+ key_prefix: str,
616
+ ) -> None:
617
+ """Async helper: create a Tenant + ApiKey row in the database."""
618
+ try:
619
+ from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
620
+ from mannf.product.billing.plans import get_plan # noqa: PLC0415
621
+
622
+ if _build_engine() is None or _async_session_factory is None:
623
+ logger.warning("No database configured — skipping tenant creation")
624
+ return
625
+
626
+ from mannf.product.models import ApiKey, Tenant # noqa: PLC0415
627
+
628
+ plan = get_plan(plan_tier)
629
+
630
+ async with _async_session_factory() as session:
631
+ tenant = Tenant(
632
+ id=uuid.uuid4(),
633
+ name=customer_name,
634
+ email=customer_email,
635
+ plan_tier=plan_tier,
636
+ stripe_customer_id=customer_id,
637
+ stripe_subscription_id=subscription_id,
638
+ monthly_scan_quota=plan.monthly_scan_quota,
639
+ monthly_security_scan_quota=plan.monthly_security_scan_quota,
640
+ max_concurrent_scans=plan.max_concurrent_scans,
641
+ is_active=True,
642
+ )
643
+ session.add(tenant)
644
+ await session.flush()
645
+
646
+ api_key_obj = ApiKey(
647
+ id=uuid.uuid4(),
648
+ tenant_id=tenant.id,
649
+ key_hash=key_hash,
650
+ key_prefix=key_prefix,
651
+ label="Default",
652
+ is_active=True,
653
+ )
654
+ session.add(api_key_obj)
655
+ await session.commit()
656
+
657
+ logger.info(
658
+ "Created tenant %s (plan=%s) from Stripe checkout", tenant.id, plan_tier
659
+ )
660
+ except ImportError:
661
+ logger.warning("SQLAlchemy models not available — skipping tenant creation")
662
+ except Exception as exc: # noqa: BLE001
663
+ logger.error("_create_tenant_and_key failed: %s", exc)
664
+
665
+
666
+ async def _upsert_tenant_from_checkout(
667
+ *,
668
+ customer_id: str,
669
+ customer_email: str,
670
+ customer_name: str,
671
+ subscription_id: str,
672
+ plan_tier: str,
673
+ key_hash: str,
674
+ key_prefix: str,
675
+ module_addons: list[str] | None = None,
676
+ ) -> None:
677
+ """Idempotent checkout completion: update existing tenant or create new one.
678
+
679
+ If a Tenant with this ``stripe_customer_id`` already exists (e.g. created
680
+ by the sync endpoint), update its plan, quotas, and subscription ID.
681
+ Otherwise, create a new Tenant + ApiKey as ``_create_tenant_and_key`` does.
682
+ """
683
+ try:
684
+ from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
685
+ from mannf.product.billing.plans import get_plan # noqa: PLC0415
686
+
687
+ if _build_engine() is None or _async_session_factory is None:
688
+ logger.warning("No database configured — skipping tenant upsert")
689
+ return
690
+
691
+ from sqlalchemy import select # noqa: PLC0415
692
+ from mannf.product.models import ApiKey, Tenant # noqa: PLC0415
693
+
694
+ plan = get_plan(plan_tier)
695
+ addons = module_addons or []
696
+
697
+ async with _async_session_factory() as session:
698
+ stmt = select(Tenant).where(Tenant.stripe_customer_id == customer_id)
699
+ result = await session.execute(stmt)
700
+ existing = result.scalar_one_or_none()
701
+
702
+ if existing is not None:
703
+ # Tenant already exists — update plan, quotas, subscription
704
+ existing.plan_tier = plan_tier
705
+ existing.monthly_scan_quota = plan.monthly_scan_quota
706
+ existing.monthly_security_scan_quota = plan.monthly_security_scan_quota
707
+ existing.max_concurrent_scans = plan.max_concurrent_scans
708
+ if subscription_id:
709
+ existing.stripe_subscription_id = subscription_id
710
+ if addons and hasattr(existing, "module_addons"):
711
+ existing.module_addons = addons
712
+ await session.commit()
713
+ logger.info(
714
+ "Updated existing tenant %s (plan=%s, addons=%s) from Stripe checkout",
715
+ existing.id,
716
+ plan_tier,
717
+ addons,
718
+ )
719
+ else:
720
+ # No existing tenant — create Tenant + ApiKey
721
+ tenant_kwargs: dict = dict(
722
+ id=uuid.uuid4(),
723
+ name=customer_name,
724
+ email=customer_email,
725
+ plan_tier=plan_tier,
726
+ stripe_customer_id=customer_id,
727
+ stripe_subscription_id=subscription_id,
728
+ monthly_scan_quota=plan.monthly_scan_quota,
729
+ monthly_security_scan_quota=plan.monthly_security_scan_quota,
730
+ max_concurrent_scans=plan.max_concurrent_scans,
731
+ is_active=True,
732
+ )
733
+ tenant = Tenant(**tenant_kwargs)
734
+ session.add(tenant)
735
+ await session.flush()
736
+
737
+ api_key_obj = ApiKey(
738
+ id=uuid.uuid4(),
739
+ tenant_id=tenant.id,
740
+ key_hash=key_hash,
741
+ key_prefix=key_prefix,
742
+ label="Default",
743
+ is_active=True,
744
+ )
745
+ session.add(api_key_obj)
746
+ await session.commit()
747
+ logger.info(
748
+ "Created tenant %s (plan=%s, addons=%s) from Stripe checkout",
749
+ tenant.id,
750
+ plan_tier,
751
+ addons,
752
+ )
753
+ except ImportError:
754
+ logger.warning("SQLAlchemy models not available — skipping tenant upsert")
755
+ except Exception as exc: # noqa: BLE001
756
+ logger.error("_upsert_tenant_from_checkout failed: %s", exc)
757
+
758
+
759
+ async def _update_tenant_plan(
760
+ customer_id: str,
761
+ plan_tier: str,
762
+ *,
763
+ subscription_id: str = "",
764
+ module_addons: list[str] | None = None,
765
+ ) -> None:
766
+ """Async helper: update Tenant.plan_tier, quota columns, subscription ID, and module add-ons."""
767
+ try:
768
+ from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
769
+
770
+ if _build_engine() is None or _async_session_factory is None:
771
+ return
772
+
773
+ from sqlalchemy import select, update # noqa: PLC0415
774
+ from mannf.product.models import Tenant # noqa: PLC0415
775
+ from mannf.product.billing.plans import get_plan # noqa: PLC0415
776
+
777
+ plan = get_plan(plan_tier)
778
+ values: dict = {
779
+ "plan_tier": plan_tier,
780
+ "monthly_scan_quota": plan.monthly_scan_quota,
781
+ "monthly_security_scan_quota": plan.monthly_security_scan_quota,
782
+ "max_concurrent_scans": plan.max_concurrent_scans,
783
+ }
784
+ if subscription_id:
785
+ values["stripe_subscription_id"] = subscription_id
786
+
787
+ async with _async_session_factory() as session:
788
+ # Update base plan fields
789
+ stmt = (
790
+ update(Tenant)
791
+ .where(Tenant.stripe_customer_id == customer_id)
792
+ .values(**values)
793
+ )
794
+ await session.execute(stmt)
795
+
796
+ # Update module_addons if the model supports it and addons were provided
797
+ if module_addons is not None:
798
+ result = await session.execute(
799
+ select(Tenant).where(Tenant.stripe_customer_id == customer_id)
800
+ )
801
+ tenant = result.scalar_one_or_none()
802
+ if tenant is not None and hasattr(tenant, "module_addons"):
803
+ tenant.module_addons = module_addons
804
+
805
+ await session.commit()
806
+
807
+ logger.info(
808
+ "Updated tenant plan for customer %s to %s (addons=%s)",
809
+ customer_id,
810
+ plan_tier,
811
+ module_addons,
812
+ )
813
+ except ImportError:
814
+ pass
815
+ except Exception as exc: # noqa: BLE001
816
+ logger.error("_update_tenant_plan failed: %s", exc)
817
+
818
+
819
+ async def _deactivate_tenant(customer_id: str) -> None:
820
+ """Async helper: set Tenant.is_active = False."""
821
+ try:
822
+ from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
823
+
824
+ if _build_engine() is None or _async_session_factory is None:
825
+ return
826
+
827
+ from sqlalchemy import update # noqa: PLC0415
828
+ from mannf.product.models import Tenant # noqa: PLC0415
829
+
830
+ async with _async_session_factory() as session:
831
+ stmt = (
832
+ update(Tenant)
833
+ .where(Tenant.stripe_customer_id == customer_id)
834
+ .values(is_active=False)
835
+ )
836
+ await session.execute(stmt)
837
+ await session.commit()
838
+
839
+ logger.info("Deactivated tenant for customer %s", customer_id)
840
+ except ImportError:
841
+ pass
842
+ except Exception as exc: # noqa: BLE001
843
+ logger.error("_deactivate_tenant failed: %s", exc)
844
+
845
+
846
+ # ---------------------------------------------------------------------------
847
+ # Free-plan direct signup (no Stripe)
848
+ # ---------------------------------------------------------------------------
849
+
850
+
851
+ async def create_free_tenant(name: str, email: str) -> dict[str, Any]:
852
+ """Create a free-tier Tenant + ApiKey directly (no Stripe needed).
853
+
854
+ Returns a dict containing the tenant record and the plaintext API key
855
+ (shown once).
856
+
857
+ Raises:
858
+ RuntimeError: if the database is not configured.
859
+ """
860
+ try:
861
+ from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
862
+
863
+ if _build_engine() is None or _async_session_factory is None:
864
+ raise RuntimeError("Database not configured — cannot create tenant")
865
+
866
+ from mannf.product.models import ApiKey, Tenant # noqa: PLC0415
867
+ from mannf.product.billing.plans import get_plan # noqa: PLC0415
868
+
869
+ plan = get_plan("free")
870
+ plaintext_key, key_hash, key_prefix = generate_api_key()
871
+
872
+ async with _async_session_factory() as session:
873
+ tenant = Tenant(
874
+ id=uuid.uuid4(),
875
+ name=name,
876
+ email=email,
877
+ plan_tier="free",
878
+ monthly_scan_quota=plan.monthly_scan_quota,
879
+ monthly_security_scan_quota=plan.monthly_security_scan_quota,
880
+ max_concurrent_scans=plan.max_concurrent_scans,
881
+ is_active=True,
882
+ )
883
+ session.add(tenant)
884
+ await session.flush()
885
+
886
+ api_key_obj = ApiKey(
887
+ id=uuid.uuid4(),
888
+ tenant_id=tenant.id,
889
+ key_hash=key_hash,
890
+ key_prefix=key_prefix,
891
+ label="Default",
892
+ is_active=True,
893
+ )
894
+ session.add(api_key_obj)
895
+ await session.commit()
896
+
897
+ logger.info("Created free tenant %s (%s)", tenant.id, email)
898
+ return {
899
+ "tenant_id": str(tenant.id),
900
+ "email": email,
901
+ "plan": "free",
902
+ "api_key": plaintext_key,
903
+ }
904
+
905
+ except ImportError as exc:
906
+ raise RuntimeError("Database models not available") from exc