svc-infra 0.1.506__py3-none-any.whl → 0.1.654__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 (202) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/alembic.py +11 -0
  3. svc_infra/apf_payments/models.py +339 -0
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +797 -0
  6. svc_infra/apf_payments/provider/base.py +270 -0
  7. svc_infra/apf_payments/provider/registry.py +31 -0
  8. svc_infra/apf_payments/provider/stripe.py +873 -0
  9. svc_infra/apf_payments/schemas.py +333 -0
  10. svc_infra/apf_payments/service.py +892 -0
  11. svc_infra/apf_payments/settings.py +67 -0
  12. svc_infra/api/fastapi/__init__.py +6 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +231 -0
  15. svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
  16. svc_infra/api/fastapi/apf_payments/router.py +1082 -0
  17. svc_infra/api/fastapi/apf_payments/setup.py +73 -0
  18. svc_infra/api/fastapi/auth/add.py +15 -6
  19. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  20. svc_infra/api/fastapi/auth/mfa/router.py +18 -9
  21. svc_infra/api/fastapi/auth/routers/account.py +3 -2
  22. svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
  23. svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
  24. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  25. svc_infra/api/fastapi/auth/security.py +3 -1
  26. svc_infra/api/fastapi/auth/settings.py +2 -0
  27. svc_infra/api/fastapi/auth/state.py +1 -1
  28. svc_infra/api/fastapi/billing/router.py +64 -0
  29. svc_infra/api/fastapi/billing/setup.py +19 -0
  30. svc_infra/api/fastapi/cache/add.py +9 -5
  31. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  32. svc_infra/api/fastapi/db/sql/add.py +40 -18
  33. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  34. svc_infra/api/fastapi/db/sql/session.py +16 -0
  35. svc_infra/api/fastapi/db/sql/users.py +14 -2
  36. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  37. svc_infra/api/fastapi/docs/add.py +160 -0
  38. svc_infra/api/fastapi/docs/landing.py +1 -1
  39. svc_infra/api/fastapi/docs/scoped.py +254 -0
  40. svc_infra/api/fastapi/dual/dualize.py +38 -33
  41. svc_infra/api/fastapi/dual/router.py +48 -1
  42. svc_infra/api/fastapi/dx.py +3 -3
  43. svc_infra/api/fastapi/http/__init__.py +0 -0
  44. svc_infra/api/fastapi/http/concurrency.py +14 -0
  45. svc_infra/api/fastapi/http/conditional.py +33 -0
  46. svc_infra/api/fastapi/http/deprecation.py +21 -0
  47. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  48. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  49. svc_infra/api/fastapi/middleware/idempotency.py +116 -0
  50. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  51. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  52. svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
  53. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  54. svc_infra/api/fastapi/middleware/request_id.py +23 -0
  55. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  56. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  57. svc_infra/api/fastapi/openapi/mutators.py +768 -55
  58. svc_infra/api/fastapi/ops/add.py +73 -0
  59. svc_infra/api/fastapi/pagination.py +363 -0
  60. svc_infra/api/fastapi/paths/auth.py +14 -14
  61. svc_infra/api/fastapi/paths/prefix.py +0 -1
  62. svc_infra/api/fastapi/paths/user.py +1 -1
  63. svc_infra/api/fastapi/routers/ping.py +1 -0
  64. svc_infra/api/fastapi/setup.py +48 -15
  65. svc_infra/api/fastapi/tenancy/add.py +19 -0
  66. svc_infra/api/fastapi/tenancy/context.py +112 -0
  67. svc_infra/api/fastapi/versioned.py +101 -0
  68. svc_infra/app/README.md +5 -5
  69. svc_infra/billing/__init__.py +23 -0
  70. svc_infra/billing/async_service.py +147 -0
  71. svc_infra/billing/jobs.py +230 -0
  72. svc_infra/billing/models.py +131 -0
  73. svc_infra/billing/quotas.py +101 -0
  74. svc_infra/billing/schemas.py +33 -0
  75. svc_infra/billing/service.py +115 -0
  76. svc_infra/bundled_docs/README.md +5 -0
  77. svc_infra/bundled_docs/__init__.py +1 -0
  78. svc_infra/bundled_docs/getting-started.md +6 -0
  79. svc_infra/cache/__init__.py +4 -0
  80. svc_infra/cache/add.py +158 -0
  81. svc_infra/cache/backend.py +5 -2
  82. svc_infra/cache/decorators.py +19 -1
  83. svc_infra/cache/keys.py +24 -4
  84. svc_infra/cli/__init__.py +32 -8
  85. svc_infra/cli/__main__.py +4 -0
  86. svc_infra/cli/cmds/__init__.py +10 -0
  87. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  88. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  89. svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
  90. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  91. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
  92. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  93. svc_infra/cli/cmds/dx/__init__.py +12 -0
  94. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  95. svc_infra/cli/cmds/help.py +4 -0
  96. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  97. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  98. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  99. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  100. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  101. svc_infra/data/add.py +61 -0
  102. svc_infra/data/backup.py +53 -0
  103. svc_infra/data/erasure.py +45 -0
  104. svc_infra/data/fixtures.py +40 -0
  105. svc_infra/data/retention.py +55 -0
  106. svc_infra/db/inbox.py +67 -0
  107. svc_infra/db/nosql/mongo/README.md +13 -13
  108. svc_infra/db/outbox.py +104 -0
  109. svc_infra/db/sql/apikey.py +1 -1
  110. svc_infra/db/sql/authref.py +61 -0
  111. svc_infra/db/sql/core.py +2 -2
  112. svc_infra/db/sql/repository.py +52 -12
  113. svc_infra/db/sql/resource.py +5 -0
  114. svc_infra/db/sql/scaffold.py +16 -4
  115. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  116. svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
  117. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
  118. svc_infra/db/sql/tenant.py +79 -0
  119. svc_infra/db/sql/utils.py +18 -4
  120. svc_infra/db/sql/versioning.py +14 -0
  121. svc_infra/docs/acceptance-matrix.md +71 -0
  122. svc_infra/docs/acceptance.md +44 -0
  123. svc_infra/docs/admin.md +425 -0
  124. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  125. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  126. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  127. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  128. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  129. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  130. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  131. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  132. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  133. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  134. svc_infra/docs/api.md +59 -0
  135. svc_infra/docs/auth.md +11 -0
  136. svc_infra/docs/billing.md +190 -0
  137. svc_infra/docs/cache.md +76 -0
  138. svc_infra/docs/cli.md +74 -0
  139. svc_infra/docs/contributing.md +34 -0
  140. svc_infra/docs/data-lifecycle.md +52 -0
  141. svc_infra/docs/database.md +14 -0
  142. svc_infra/docs/docs-and-sdks.md +62 -0
  143. svc_infra/docs/environment.md +114 -0
  144. svc_infra/docs/getting-started.md +63 -0
  145. svc_infra/docs/idempotency.md +111 -0
  146. svc_infra/docs/jobs.md +67 -0
  147. svc_infra/docs/observability.md +16 -0
  148. svc_infra/docs/ops.md +37 -0
  149. svc_infra/docs/rate-limiting.md +125 -0
  150. svc_infra/docs/repo-review.md +48 -0
  151. svc_infra/docs/security.md +176 -0
  152. svc_infra/docs/tenancy.md +35 -0
  153. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  154. svc_infra/docs/versioned-integrations.md +146 -0
  155. svc_infra/docs/webhooks.md +112 -0
  156. svc_infra/dx/add.py +63 -0
  157. svc_infra/dx/changelog.py +74 -0
  158. svc_infra/dx/checks.py +67 -0
  159. svc_infra/http/__init__.py +13 -0
  160. svc_infra/http/client.py +72 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  162. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  163. svc_infra/jobs/easy.py +32 -0
  164. svc_infra/jobs/loader.py +45 -0
  165. svc_infra/jobs/queue.py +81 -0
  166. svc_infra/jobs/redis_queue.py +191 -0
  167. svc_infra/jobs/runner.py +75 -0
  168. svc_infra/jobs/scheduler.py +41 -0
  169. svc_infra/jobs/worker.py +40 -0
  170. svc_infra/mcp/svc_infra_mcp.py +85 -28
  171. svc_infra/obs/README.md +2 -0
  172. svc_infra/obs/add.py +54 -7
  173. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  174. svc_infra/obs/metrics/__init__.py +53 -0
  175. svc_infra/obs/metrics.py +52 -0
  176. svc_infra/security/add.py +201 -0
  177. svc_infra/security/audit.py +130 -0
  178. svc_infra/security/audit_service.py +73 -0
  179. svc_infra/security/headers.py +52 -0
  180. svc_infra/security/hibp.py +95 -0
  181. svc_infra/security/jwt_rotation.py +53 -0
  182. svc_infra/security/lockout.py +96 -0
  183. svc_infra/security/models.py +255 -0
  184. svc_infra/security/org_invites.py +128 -0
  185. svc_infra/security/passwords.py +77 -0
  186. svc_infra/security/permissions.py +149 -0
  187. svc_infra/security/session.py +98 -0
  188. svc_infra/security/signed_cookies.py +80 -0
  189. svc_infra/webhooks/__init__.py +16 -0
  190. svc_infra/webhooks/add.py +322 -0
  191. svc_infra/webhooks/fastapi.py +37 -0
  192. svc_infra/webhooks/router.py +55 -0
  193. svc_infra/webhooks/service.py +67 -0
  194. svc_infra/webhooks/signing.py +30 -0
  195. svc_infra-0.1.654.dist-info/METADATA +154 -0
  196. svc_infra-0.1.654.dist-info/RECORD +352 -0
  197. svc_infra/api/fastapi/deps.py +0 -3
  198. svc_infra-0.1.506.dist-info/METADATA +0 -78
  199. svc_infra-0.1.506.dist-info/RECORD +0 -213
  200. /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
  201. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  202. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated, Any, Callable, Optional
4
+
5
+ from fastapi import Depends, HTTPException, Request
6
+
7
+ try: # optional import; auth may not be used by all consumers
8
+ from svc_infra.api.fastapi.auth.security import OptionalIdentity
9
+ except Exception: # pragma: no cover - fallback for minimal builds
10
+ OptionalIdentity = None # type: ignore
11
+
12
+
13
+ _tenant_resolver: Optional[Callable[..., Any]] = None
14
+
15
+
16
+ def set_tenant_resolver(
17
+ fn: Optional[Callable[..., Any]],
18
+ ) -> None:
19
+ """Set or clear a global override hook for tenant resolution.
20
+
21
+ The function receives (request, identity, tenant_header) and should return a tenant id
22
+ string or None to fall back to default logic.
23
+ """
24
+ global _tenant_resolver
25
+ _tenant_resolver = fn
26
+
27
+
28
+ async def _maybe_await(x):
29
+ if callable(getattr(x, "__await__", None)):
30
+ return await x # type: ignore[misc]
31
+ return x
32
+
33
+
34
+ async def resolve_tenant_id(
35
+ request: Request,
36
+ tenant_header: Optional[str] = None,
37
+ identity: Any = Depends(OptionalIdentity) if OptionalIdentity else None, # type: ignore[arg-type]
38
+ ) -> Optional[str]:
39
+ """Resolve tenant id from override, identity, header, or request.state.
40
+
41
+ Order:
42
+ 1) Global override hook (set_tenant_resolver)
43
+ 2) Auth identity: user.tenant_id then api_key.tenant_id (if available)
44
+ 3) X-Tenant-Id header
45
+ 4) request.state.tenant_id
46
+ """
47
+ # read header value if not provided directly (supports direct calls without DI)
48
+ if tenant_header is None:
49
+ try:
50
+ tenant_header = request.headers.get("X-Tenant-Id") # type: ignore[assignment]
51
+ except Exception:
52
+ tenant_header = None
53
+
54
+ # 1) global override
55
+ if _tenant_resolver is not None:
56
+ try:
57
+ v = _tenant_resolver(request, identity, tenant_header)
58
+ v2 = await _maybe_await(v)
59
+ if v2:
60
+ return str(v2)
61
+ except Exception:
62
+ # fall through to defaults
63
+ pass
64
+
65
+ # 2) from identity
66
+ try:
67
+ if identity and getattr(identity, "user", None) is not None:
68
+ tid = getattr(identity.user, "tenant_id", None)
69
+ if tid:
70
+ return str(tid)
71
+ if identity and getattr(identity, "api_key", None) is not None:
72
+ tid = getattr(identity.api_key, "tenant_id", None)
73
+ if tid:
74
+ return str(tid)
75
+ except Exception:
76
+ pass
77
+
78
+ # 3) from header
79
+ if tenant_header and isinstance(tenant_header, str) and tenant_header.strip():
80
+ return tenant_header.strip()
81
+
82
+ # 4) request.state
83
+ try:
84
+ st_tid = getattr(getattr(request, "state", object()), "tenant_id", None)
85
+ if st_tid:
86
+ return str(st_tid)
87
+ except Exception:
88
+ pass
89
+
90
+ return None
91
+
92
+
93
+ async def require_tenant_id(
94
+ tenant_id: Optional[str] = Depends(resolve_tenant_id),
95
+ ) -> str:
96
+ if not tenant_id:
97
+ raise HTTPException(status_code=400, detail="tenant_context_missing")
98
+ return tenant_id
99
+
100
+
101
+ # DX aliases
102
+ TenantId = Annotated[str, Depends(require_tenant_id)]
103
+ OptionalTenantId = Annotated[Optional[str], Depends(resolve_tenant_id)]
104
+
105
+
106
+ __all__ = [
107
+ "TenantId",
108
+ "OptionalTenantId",
109
+ "resolve_tenant_id",
110
+ "require_tenant_id",
111
+ "set_tenant_resolver",
112
+ ]
@@ -0,0 +1,101 @@
1
+ """
2
+ Utilities for capturing routers from add_* functions for versioned routing.
3
+
4
+ This module provides helpers to use integration functions (add_banking, add_payments, etc.)
5
+ under versioned routing without creating separate documentation cards.
6
+
7
+ See: svc-infra/docs/versioned-integrations.md
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Callable, TypeVar
13
+ from unittest.mock import patch
14
+
15
+ from fastapi import APIRouter, FastAPI
16
+
17
+ __all__ = ["extract_router"]
18
+
19
+ T = TypeVar("T")
20
+
21
+
22
+ def extract_router(
23
+ add_function: Callable[..., T],
24
+ *,
25
+ prefix: str,
26
+ **kwargs: Any,
27
+ ) -> tuple[APIRouter, T]:
28
+ """
29
+ Capture the router from an add_* function for versioned mounting.
30
+
31
+ This allows you to use integration functions like add_banking(), add_payments(),
32
+ etc. under versioned routing (e.g., /v0/banking) without creating separate
33
+ documentation cards.
34
+
35
+ Args:
36
+ add_function: The add_* function to capture from (e.g., add_banking)
37
+ prefix: URL prefix for the routes (e.g., "/banking")
38
+ **kwargs: Arguments to pass to the add_function
39
+
40
+ Returns:
41
+ Tuple of (router, return_value) where:
42
+ - router: The captured APIRouter with all routes
43
+ - return_value: The original return value from add_function (e.g., provider instance)
44
+
45
+ Example:
46
+ ```python
47
+ # In routers/v0/banking.py
48
+ from svc_infra.api.fastapi.versioned import extract_router
49
+ from fin_infra.banking import add_banking
50
+
51
+ router, banking_provider = extract_router(
52
+ add_banking,
53
+ prefix="/banking",
54
+ provider="plaid",
55
+ cache_ttl=60,
56
+ )
57
+
58
+ # svc-infra auto-discovers 'router' and mounts at /v0/banking
59
+ ```
60
+
61
+ Pattern:
62
+ 1. Creates a mock FastAPI app
63
+ 2. Intercepts include_router to capture the router
64
+ 3. Patches add_prefixed_docs to prevent separate card creation
65
+ 4. Calls the add_function which creates all routes
66
+ 5. Returns the captured router for auto-discovery
67
+
68
+ See Also:
69
+ - docs/versioned-integrations.md: Full pattern documentation
70
+ - api/fastapi/dual/public.py: Similar pattern for dual routers
71
+ """
72
+ # Create mock app to capture router
73
+ mock_app = FastAPI()
74
+ captured_router: APIRouter | None = None
75
+
76
+ def _capture_router(router: APIRouter, **_kwargs: Any) -> None:
77
+ """Intercept include_router to capture instead of mount."""
78
+ nonlocal captured_router
79
+ captured_router = router
80
+
81
+ mock_app.include_router = _capture_router # type: ignore[assignment]
82
+
83
+ # Patch add_prefixed_docs to prevent separate card (no-op if function doesn't call it)
84
+ def _noop_docs(*args: Any, **kwargs: Any) -> None:
85
+ pass
86
+
87
+ # Call add_function with patches active
88
+ with patch("svc_infra.api.fastapi.docs.scoped.add_prefixed_docs", _noop_docs):
89
+ result = add_function(
90
+ mock_app,
91
+ prefix=prefix,
92
+ **kwargs,
93
+ )
94
+
95
+ if captured_router is None:
96
+ raise RuntimeError(
97
+ f"Failed to capture router from {add_function.__name__}. "
98
+ f"The function may not call app.include_router()."
99
+ )
100
+
101
+ return captured_router, result
svc_infra/app/README.md CHANGED
@@ -14,9 +14,8 @@ This README shows:
14
14
 
15
15
  ```python
16
16
  # main.py (or wherever your app starts)
17
- from svc_infra.logging.logging import setup_logging
17
+ from svc_infra.app.logging import setup_logging, LogLevelOptions
18
18
  from svc_infra.app.env import pick
19
- from svc_infra.logging.logging import LogLevelOptions
20
19
  ```
21
20
 
22
21
  ---
@@ -39,7 +38,8 @@ What you get by default:
39
38
  Set via code:
40
39
 
41
40
  ```python
42
- from svc_infra.logging.logging import LogFormatOptions, LogLevelOptions
41
+ from svc_infra.app.logging.formats import LogFormatOptions
42
+ from svc_infra.app.logging import LogLevelOptions
43
43
 
44
44
  setup_logging(
45
45
  level=LogLevelOptions.INFO, # or "INFO"
@@ -119,7 +119,7 @@ Old (pre-filter) example:
119
119
 
120
120
  ```python
121
121
  from svc_infra.app.env import pick
122
- from svc_infra.logging.logging import setup_logging, LogLevelOptions
122
+ from svc_infra.app.logging import setup_logging, LogLevelOptions
123
123
 
124
124
  setup_logging(
125
125
  level=pick(
@@ -183,7 +183,7 @@ LOG_DROP_PATHS=/metrics,/health,/healthz
183
183
  ## 7) One-liner quickstart
184
184
 
185
185
  ```python
186
- from svc_infra.logging import setup_logging
186
+ from svc_infra.app.logging import setup_logging
187
187
  setup_logging() # done: sensible defaults + filters in prod/test
188
188
  ```
189
189
 
@@ -0,0 +1,23 @@
1
+ from .models import (
2
+ Invoice,
3
+ InvoiceLine,
4
+ Plan,
5
+ PlanEntitlement,
6
+ Price,
7
+ Subscription,
8
+ UsageAggregate,
9
+ UsageEvent,
10
+ )
11
+ from .service import BillingService
12
+
13
+ __all__ = [
14
+ "UsageEvent",
15
+ "UsageAggregate",
16
+ "Plan",
17
+ "PlanEntitlement",
18
+ "Subscription",
19
+ "Price",
20
+ "Invoice",
21
+ "InvoiceLine",
22
+ "BillingService",
23
+ ]
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import Optional, Sequence
6
+
7
+ from sqlalchemy import select
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from .models import Invoice, InvoiceLine, UsageAggregate, UsageEvent
11
+
12
+
13
+ class AsyncBillingService:
14
+ def __init__(self, session: AsyncSession, tenant_id: str):
15
+ self.session = session
16
+ self.tenant_id = tenant_id
17
+
18
+ async def record_usage(
19
+ self,
20
+ *,
21
+ metric: str,
22
+ amount: int,
23
+ at: datetime,
24
+ idempotency_key: str,
25
+ metadata: dict | None,
26
+ ) -> str:
27
+ if at.tzinfo is None:
28
+ at = at.replace(tzinfo=timezone.utc)
29
+ evt = UsageEvent(
30
+ id=str(uuid.uuid4()),
31
+ tenant_id=self.tenant_id,
32
+ metric=metric,
33
+ amount=amount,
34
+ at_ts=at,
35
+ idempotency_key=idempotency_key,
36
+ metadata_json=metadata or {},
37
+ )
38
+ self.session.add(evt)
39
+ await self.session.flush()
40
+ return evt.id
41
+
42
+ async def aggregate_daily(self, *, metric: str, day_start: datetime) -> int:
43
+ day_start = day_start.replace(
44
+ hour=0, minute=0, second=0, microsecond=0, tzinfo=timezone.utc
45
+ )
46
+ next_day = day_start + timedelta(days=1)
47
+ total = 0
48
+ rows: Sequence[UsageEvent] = (
49
+ (
50
+ await self.session.execute(
51
+ select(UsageEvent).where(
52
+ UsageEvent.tenant_id == self.tenant_id,
53
+ UsageEvent.metric == metric,
54
+ UsageEvent.at_ts >= day_start,
55
+ UsageEvent.at_ts < next_day,
56
+ )
57
+ )
58
+ )
59
+ .scalars()
60
+ .all()
61
+ )
62
+ for r in rows:
63
+ total += int(r.amount)
64
+
65
+ agg = (
66
+ await self.session.execute(
67
+ select(UsageAggregate).where(
68
+ UsageAggregate.tenant_id == self.tenant_id,
69
+ UsageAggregate.metric == metric,
70
+ UsageAggregate.period_start == day_start,
71
+ UsageAggregate.granularity == "day",
72
+ )
73
+ )
74
+ ).scalar_one_or_none()
75
+ if agg:
76
+ agg.total = total
77
+ else:
78
+ self.session.add(
79
+ UsageAggregate(
80
+ id=str(uuid.uuid4()),
81
+ tenant_id=self.tenant_id,
82
+ metric=metric,
83
+ period_start=day_start,
84
+ granularity="day",
85
+ total=total,
86
+ )
87
+ )
88
+ return total
89
+
90
+ async def list_daily_aggregates(
91
+ self, *, metric: str, date_from: Optional[datetime], date_to: Optional[datetime]
92
+ ) -> list[UsageAggregate]:
93
+ q = select(UsageAggregate).where(
94
+ UsageAggregate.tenant_id == self.tenant_id,
95
+ UsageAggregate.metric == metric,
96
+ UsageAggregate.granularity == "day",
97
+ )
98
+ if date_from is not None:
99
+ q = q.where(UsageAggregate.period_start >= date_from)
100
+ if date_to is not None:
101
+ q = q.where(UsageAggregate.period_start < date_to)
102
+ rows: list[UsageAggregate] = (await self.session.execute(q)).scalars().all()
103
+ return rows
104
+
105
+ async def generate_monthly_invoice(
106
+ self, *, period_start: datetime, period_end: datetime, currency: str
107
+ ) -> str:
108
+ total = 0
109
+ aggs: Sequence[UsageAggregate] = (
110
+ (
111
+ await self.session.execute(
112
+ select(UsageAggregate).where(
113
+ UsageAggregate.tenant_id == self.tenant_id,
114
+ UsageAggregate.period_start >= period_start,
115
+ UsageAggregate.period_start < period_end,
116
+ UsageAggregate.granularity == "day",
117
+ )
118
+ )
119
+ )
120
+ .scalars()
121
+ .all()
122
+ )
123
+ for r in aggs:
124
+ total += int(r.total)
125
+
126
+ inv = Invoice(
127
+ id=str(uuid.uuid4()),
128
+ tenant_id=self.tenant_id,
129
+ period_start=period_start,
130
+ period_end=period_end,
131
+ status="created",
132
+ total_amount=total,
133
+ currency=currency,
134
+ )
135
+ self.session.add(inv)
136
+ await self.session.flush()
137
+
138
+ line = InvoiceLine(
139
+ id=str(uuid.uuid4()),
140
+ invoice_id=inv.id,
141
+ price_id=None,
142
+ metric=None,
143
+ quantity=1,
144
+ amount=total,
145
+ )
146
+ self.session.add(line)
147
+ return inv.id
@@ -0,0 +1,230 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Awaitable, Callable, Dict, Optional
6
+
7
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
8
+
9
+ from svc_infra.jobs.queue import Job, JobQueue
10
+ from svc_infra.jobs.scheduler import InMemoryScheduler
11
+ from svc_infra.webhooks.service import WebhookService
12
+
13
+ from .async_service import AsyncBillingService
14
+
15
+
16
+ async def job_aggregate_daily(
17
+ session: AsyncSession, *, tenant_id: str, metric: str, day_start: datetime
18
+ ) -> None:
19
+ """
20
+ Aggregate usage for a tenant/metric for the given day_start (UTC).
21
+
22
+ Intended to be called from a scheduler/worker with an AsyncSession created by the host app.
23
+ """
24
+ svc = AsyncBillingService(session=session, tenant_id=tenant_id)
25
+ if day_start.tzinfo is None:
26
+ day_start = day_start.replace(tzinfo=timezone.utc)
27
+ await svc.aggregate_daily(metric=metric, day_start=day_start)
28
+
29
+
30
+ async def job_generate_monthly_invoice(
31
+ session: AsyncSession,
32
+ *,
33
+ tenant_id: str,
34
+ period_start: datetime,
35
+ period_end: datetime,
36
+ currency: str,
37
+ ) -> str:
38
+ """
39
+ Generate a monthly invoice for a tenant between [period_start, period_end).
40
+ Returns the internal invoice id.
41
+ """
42
+ svc = AsyncBillingService(session=session, tenant_id=tenant_id)
43
+ if period_start.tzinfo is None:
44
+ period_start = period_start.replace(tzinfo=timezone.utc)
45
+ if period_end.tzinfo is None:
46
+ period_end = period_end.replace(tzinfo=timezone.utc)
47
+ return await svc.generate_monthly_invoice(
48
+ period_start=period_start, period_end=period_end, currency=currency
49
+ )
50
+
51
+
52
+ # -------- Job helpers and handlers (scheduler/worker wiring) ---------
53
+
54
+ BILLING_AGGREGATE_JOB = "billing.aggregate_daily"
55
+ BILLING_INVOICE_JOB = "billing.generate_monthly_invoice"
56
+
57
+
58
+ def enqueue_aggregate_daily(
59
+ queue: JobQueue,
60
+ *,
61
+ tenant_id: str,
62
+ metric: str,
63
+ day_start: datetime,
64
+ delay_seconds: int = 0,
65
+ ) -> None:
66
+ payload = {
67
+ "tenant_id": tenant_id,
68
+ "metric": metric,
69
+ "day_start": day_start.astimezone(timezone.utc).isoformat(),
70
+ }
71
+ queue.enqueue(BILLING_AGGREGATE_JOB, payload, delay_seconds=delay_seconds)
72
+
73
+
74
+ def enqueue_generate_monthly_invoice(
75
+ queue: JobQueue,
76
+ *,
77
+ tenant_id: str,
78
+ period_start: datetime,
79
+ period_end: datetime,
80
+ currency: str,
81
+ delay_seconds: int = 0,
82
+ ) -> None:
83
+ payload = {
84
+ "tenant_id": tenant_id,
85
+ "period_start": period_start.astimezone(timezone.utc).isoformat(),
86
+ "period_end": period_end.astimezone(timezone.utc).isoformat(),
87
+ "currency": currency,
88
+ }
89
+ queue.enqueue(BILLING_INVOICE_JOB, payload, delay_seconds=delay_seconds)
90
+
91
+
92
+ def make_daily_aggregate_tick(
93
+ queue: JobQueue,
94
+ *,
95
+ tenant_id: str,
96
+ metric: str,
97
+ when: Optional[datetime] = None,
98
+ ):
99
+ """Return an async function that enqueues a daily aggregate job.
100
+
101
+ This is a simple helper for local/dev schedulers; it schedules an aggregate
102
+ for the UTC day of ``when`` (or now). Call repeatedly via a scheduler.
103
+ """
104
+
105
+ async def _tick():
106
+ ts = (when or datetime.now(timezone.utc)).astimezone(timezone.utc)
107
+ day_start = ts.replace(hour=0, minute=0, second=0, microsecond=0)
108
+ enqueue_aggregate_daily(queue, tenant_id=tenant_id, metric=metric, day_start=day_start)
109
+
110
+ return _tick
111
+
112
+
113
+ def make_billing_job_handler(
114
+ *,
115
+ session_factory: "async_sessionmaker[AsyncSession]",
116
+ webhooks: WebhookService,
117
+ ) -> Callable[[Job], Awaitable[None]]:
118
+ """Create a worker handler that processes billing jobs and emits webhooks.
119
+
120
+ Supported jobs and their expected payloads:
121
+ - billing.aggregate_daily {tenant_id, metric, day_start: ISO8601}
122
+ → emits topic 'billing.usage_aggregated'
123
+ - billing.generate_monthly_invoice {tenant_id, period_start: ISO8601, period_end: ISO8601, currency}
124
+ → emits topic 'billing.invoice.created'
125
+ """
126
+
127
+ async def _maybe_commit(session: Any) -> None:
128
+ """Commit if the session exposes a commit method (await if coroutine).
129
+
130
+ This makes the handler resilient in tests/dev where a dummy session is used.
131
+ """
132
+ commit = getattr(session, "commit", None)
133
+ if callable(commit):
134
+ result = commit()
135
+ if inspect.isawaitable(result):
136
+ await result
137
+
138
+ async def _handler(job: Job) -> None:
139
+ name = job.name
140
+ data: Dict[str, Any] = job.payload or {}
141
+ if name == BILLING_AGGREGATE_JOB:
142
+ tenant_id = str(data.get("tenant_id"))
143
+ metric = str(data.get("metric"))
144
+ day_raw = data.get("day_start")
145
+ if not tenant_id or not metric or not day_raw:
146
+ return
147
+ day_start = datetime.fromisoformat(str(day_raw))
148
+ async with session_factory() as session:
149
+ svc = AsyncBillingService(session=session, tenant_id=tenant_id)
150
+ total = await svc.aggregate_daily(metric=metric, day_start=day_start)
151
+ await _maybe_commit(session)
152
+ webhooks.publish(
153
+ "billing.usage_aggregated",
154
+ {
155
+ "tenant_id": tenant_id,
156
+ "metric": metric,
157
+ "day_start": day_start.astimezone(timezone.utc).isoformat(),
158
+ "total": int(total),
159
+ },
160
+ )
161
+ return
162
+ if name == BILLING_INVOICE_JOB:
163
+ tenant_id = str(data.get("tenant_id"))
164
+ period_start_raw = data.get("period_start")
165
+ period_end_raw = data.get("period_end")
166
+ currency = str(data.get("currency"))
167
+ if not tenant_id or not period_start_raw or not period_end_raw or not currency:
168
+ return
169
+ period_start = datetime.fromisoformat(str(period_start_raw))
170
+ period_end = datetime.fromisoformat(str(period_end_raw))
171
+ async with session_factory() as session:
172
+ svc = AsyncBillingService(session=session, tenant_id=tenant_id)
173
+ invoice_id = await svc.generate_monthly_invoice(
174
+ period_start=period_start, period_end=period_end, currency=currency
175
+ )
176
+ await _maybe_commit(session)
177
+ webhooks.publish(
178
+ "billing.invoice.created",
179
+ {
180
+ "tenant_id": tenant_id,
181
+ "invoice_id": invoice_id,
182
+ "period_start": period_start.astimezone(timezone.utc).isoformat(),
183
+ "period_end": period_end.astimezone(timezone.utc).isoformat(),
184
+ "currency": currency,
185
+ },
186
+ )
187
+ return
188
+ # Ignore unrelated jobs
189
+
190
+ return _handler
191
+
192
+
193
+ def add_billing_jobs(
194
+ *,
195
+ scheduler: InMemoryScheduler,
196
+ queue: JobQueue,
197
+ jobs: list[dict],
198
+ ) -> None:
199
+ """Register simple interval-based billing job enqueuers.
200
+
201
+ jobs: list of dicts with shape {"name": "aggregate", "tenant_id": ..., "metric": ..., "interval_seconds": 86400}
202
+ or {"name": "invoice", "tenant_id": ..., "period_start": ISO, "period_end": ISO, "currency": ..., "interval_seconds": 2592000}
203
+ """
204
+
205
+ for j in jobs:
206
+ name = j.get("name")
207
+ interval = int(j.get("interval_seconds", 86400))
208
+ if name == "aggregate":
209
+ tenant_id = j["tenant_id"]
210
+ metric = j["metric"]
211
+
212
+ async def _tick_fn(tid=tenant_id, m=metric):
213
+ # Enqueue for the current UTC day
214
+ now = datetime.now(timezone.utc)
215
+ day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
216
+ enqueue_aggregate_daily(queue, tenant_id=tid, metric=m, day_start=day_start)
217
+
218
+ scheduler.add_task(f"billing.aggregate.{tenant_id}.{metric}", interval, _tick_fn)
219
+ elif name == "invoice":
220
+ tenant_id = j["tenant_id"]
221
+ currency = j["currency"]
222
+ pstart = datetime.fromisoformat(j["period_start"]).astimezone(timezone.utc)
223
+ pend = datetime.fromisoformat(j["period_end"]).astimezone(timezone.utc)
224
+
225
+ async def _tick_inv(tid=tenant_id, cs=currency, ps=pstart, pe=pend):
226
+ enqueue_generate_monthly_invoice(
227
+ queue, tenant_id=tid, period_start=ps, period_end=pe, currency=cs
228
+ )
229
+
230
+ scheduler.add_task(f"billing.invoice.{tenant_id}", interval, _tick_inv)