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,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
|