svc-infra 0.1.595__py3-none-any.whl → 0.1.706__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.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

Files changed (256) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +133 -42
  3. svc_infra/apf_payments/provider/aiydan.py +121 -47
  4. svc_infra/apf_payments/provider/base.py +30 -9
  5. svc_infra/apf_payments/provider/stripe.py +156 -62
  6. svc_infra/apf_payments/schemas.py +18 -9
  7. svc_infra/apf_payments/service.py +98 -41
  8. svc_infra/apf_payments/settings.py +5 -1
  9. svc_infra/api/__init__.py +61 -0
  10. svc_infra/api/fastapi/__init__.py +15 -0
  11. svc_infra/api/fastapi/admin/__init__.py +3 -0
  12. svc_infra/api/fastapi/admin/add.py +245 -0
  13. svc_infra/api/fastapi/apf_payments/router.py +128 -70
  14. svc_infra/api/fastapi/apf_payments/setup.py +13 -6
  15. svc_infra/api/fastapi/auth/__init__.py +65 -0
  16. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  17. svc_infra/api/fastapi/auth/add.py +17 -14
  18. svc_infra/api/fastapi/auth/gaurd.py +45 -16
  19. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  21. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  22. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  23. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  24. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  25. svc_infra/api/fastapi/auth/policy.py +0 -1
  26. svc_infra/api/fastapi/auth/providers.py +3 -1
  27. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  28. svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
  29. svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
  30. svc_infra/api/fastapi/auth/security.py +31 -10
  31. svc_infra/api/fastapi/auth/sender.py +8 -1
  32. svc_infra/api/fastapi/auth/state.py +3 -1
  33. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  34. svc_infra/api/fastapi/billing/router.py +73 -0
  35. svc_infra/api/fastapi/billing/setup.py +19 -0
  36. svc_infra/api/fastapi/cache/add.py +9 -5
  37. svc_infra/api/fastapi/db/__init__.py +5 -1
  38. svc_infra/api/fastapi/db/http.py +3 -1
  39. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  40. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  41. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  42. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  43. svc_infra/api/fastapi/db/sql/add.py +71 -26
  44. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  45. svc_infra/api/fastapi/db/sql/health.py +3 -1
  46. svc_infra/api/fastapi/db/sql/session.py +18 -0
  47. svc_infra/api/fastapi/db/sql/users.py +18 -6
  48. svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
  49. svc_infra/api/fastapi/docs/add.py +173 -0
  50. svc_infra/api/fastapi/docs/landing.py +4 -2
  51. svc_infra/api/fastapi/docs/scoped.py +62 -15
  52. svc_infra/api/fastapi/dual/__init__.py +12 -2
  53. svc_infra/api/fastapi/dual/dualize.py +1 -1
  54. svc_infra/api/fastapi/dual/protected.py +126 -4
  55. svc_infra/api/fastapi/dual/public.py +25 -0
  56. svc_infra/api/fastapi/dual/router.py +40 -13
  57. svc_infra/api/fastapi/dx.py +33 -2
  58. svc_infra/api/fastapi/ease.py +10 -2
  59. svc_infra/api/fastapi/http/concurrency.py +2 -1
  60. svc_infra/api/fastapi/http/conditional.py +3 -1
  61. svc_infra/api/fastapi/middleware/debug.py +4 -1
  62. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
  71. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  74. svc_infra/api/fastapi/openapi/apply.py +5 -3
  75. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  76. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  77. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  78. svc_infra/api/fastapi/openapi/security.py +3 -1
  79. svc_infra/api/fastapi/ops/add.py +75 -0
  80. svc_infra/api/fastapi/pagination.py +47 -20
  81. svc_infra/api/fastapi/routers/__init__.py +43 -15
  82. svc_infra/api/fastapi/routers/ping.py +1 -0
  83. svc_infra/api/fastapi/setup.py +188 -57
  84. svc_infra/api/fastapi/tenancy/add.py +19 -0
  85. svc_infra/api/fastapi/tenancy/context.py +112 -0
  86. svc_infra/api/fastapi/versioned.py +101 -0
  87. svc_infra/app/README.md +5 -5
  88. svc_infra/app/__init__.py +3 -1
  89. svc_infra/app/env.py +69 -1
  90. svc_infra/app/logging/add.py +9 -2
  91. svc_infra/app/logging/formats.py +12 -5
  92. svc_infra/billing/__init__.py +23 -0
  93. svc_infra/billing/async_service.py +147 -0
  94. svc_infra/billing/jobs.py +241 -0
  95. svc_infra/billing/models.py +177 -0
  96. svc_infra/billing/quotas.py +103 -0
  97. svc_infra/billing/schemas.py +36 -0
  98. svc_infra/billing/service.py +123 -0
  99. svc_infra/bundled_docs/README.md +5 -0
  100. svc_infra/bundled_docs/__init__.py +1 -0
  101. svc_infra/bundled_docs/getting-started.md +6 -0
  102. svc_infra/cache/__init__.py +9 -0
  103. svc_infra/cache/add.py +170 -0
  104. svc_infra/cache/backend.py +7 -6
  105. svc_infra/cache/decorators.py +81 -15
  106. svc_infra/cache/demo.py +2 -2
  107. svc_infra/cache/keys.py +24 -4
  108. svc_infra/cache/recache.py +26 -14
  109. svc_infra/cache/resources.py +14 -5
  110. svc_infra/cache/tags.py +19 -44
  111. svc_infra/cache/utils.py +3 -1
  112. svc_infra/cli/__init__.py +52 -8
  113. svc_infra/cli/__main__.py +4 -0
  114. svc_infra/cli/cmds/__init__.py +39 -2
  115. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  116. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  117. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  118. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  119. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  120. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  121. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  122. svc_infra/cli/cmds/dx/__init__.py +12 -0
  123. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  124. svc_infra/cli/cmds/health/__init__.py +179 -0
  125. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  126. svc_infra/cli/cmds/help.py +4 -0
  127. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  128. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  129. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  130. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  131. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  132. svc_infra/cli/foundation/runner.py +6 -2
  133. svc_infra/data/add.py +61 -0
  134. svc_infra/data/backup.py +58 -0
  135. svc_infra/data/erasure.py +45 -0
  136. svc_infra/data/fixtures.py +42 -0
  137. svc_infra/data/retention.py +61 -0
  138. svc_infra/db/__init__.py +15 -0
  139. svc_infra/db/crud_schema.py +9 -9
  140. svc_infra/db/inbox.py +67 -0
  141. svc_infra/db/nosql/__init__.py +3 -0
  142. svc_infra/db/nosql/core.py +30 -9
  143. svc_infra/db/nosql/indexes.py +3 -1
  144. svc_infra/db/nosql/management.py +1 -1
  145. svc_infra/db/nosql/mongo/README.md +13 -13
  146. svc_infra/db/nosql/mongo/client.py +19 -2
  147. svc_infra/db/nosql/mongo/settings.py +6 -2
  148. svc_infra/db/nosql/repository.py +35 -15
  149. svc_infra/db/nosql/resource.py +20 -3
  150. svc_infra/db/nosql/scaffold.py +9 -3
  151. svc_infra/db/nosql/service.py +3 -1
  152. svc_infra/db/nosql/types.py +6 -2
  153. svc_infra/db/ops.py +384 -0
  154. svc_infra/db/outbox.py +108 -0
  155. svc_infra/db/sql/apikey.py +37 -9
  156. svc_infra/db/sql/authref.py +9 -3
  157. svc_infra/db/sql/constants.py +12 -8
  158. svc_infra/db/sql/core.py +2 -2
  159. svc_infra/db/sql/management.py +11 -8
  160. svc_infra/db/sql/repository.py +99 -26
  161. svc_infra/db/sql/resource.py +5 -0
  162. svc_infra/db/sql/scaffold.py +6 -2
  163. svc_infra/db/sql/service.py +15 -5
  164. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  165. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  166. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  167. svc_infra/db/sql/tenant.py +88 -0
  168. svc_infra/db/sql/uniq_hooks.py +9 -3
  169. svc_infra/db/sql/utils.py +138 -51
  170. svc_infra/db/sql/versioning.py +14 -0
  171. svc_infra/deploy/__init__.py +538 -0
  172. svc_infra/documents/__init__.py +100 -0
  173. svc_infra/documents/add.py +264 -0
  174. svc_infra/documents/ease.py +233 -0
  175. svc_infra/documents/models.py +114 -0
  176. svc_infra/documents/storage.py +264 -0
  177. svc_infra/dx/add.py +65 -0
  178. svc_infra/dx/changelog.py +74 -0
  179. svc_infra/dx/checks.py +68 -0
  180. svc_infra/exceptions.py +141 -0
  181. svc_infra/health/__init__.py +864 -0
  182. svc_infra/http/__init__.py +13 -0
  183. svc_infra/http/client.py +105 -0
  184. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  185. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  186. svc_infra/jobs/easy.py +33 -0
  187. svc_infra/jobs/loader.py +50 -0
  188. svc_infra/jobs/queue.py +116 -0
  189. svc_infra/jobs/redis_queue.py +256 -0
  190. svc_infra/jobs/runner.py +79 -0
  191. svc_infra/jobs/scheduler.py +53 -0
  192. svc_infra/jobs/worker.py +40 -0
  193. svc_infra/loaders/__init__.py +186 -0
  194. svc_infra/loaders/base.py +142 -0
  195. svc_infra/loaders/github.py +311 -0
  196. svc_infra/loaders/models.py +147 -0
  197. svc_infra/loaders/url.py +235 -0
  198. svc_infra/logging/__init__.py +374 -0
  199. svc_infra/mcp/svc_infra_mcp.py +91 -33
  200. svc_infra/obs/README.md +2 -0
  201. svc_infra/obs/add.py +65 -9
  202. svc_infra/obs/cloud_dash.py +2 -1
  203. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  204. svc_infra/obs/metrics/__init__.py +3 -4
  205. svc_infra/obs/metrics/asgi.py +13 -7
  206. svc_infra/obs/metrics/http.py +9 -5
  207. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  208. svc_infra/obs/metrics.py +6 -5
  209. svc_infra/obs/settings.py +6 -2
  210. svc_infra/security/add.py +217 -0
  211. svc_infra/security/audit.py +92 -10
  212. svc_infra/security/audit_service.py +4 -3
  213. svc_infra/security/headers.py +15 -2
  214. svc_infra/security/hibp.py +14 -4
  215. svc_infra/security/jwt_rotation.py +74 -22
  216. svc_infra/security/lockout.py +11 -5
  217. svc_infra/security/models.py +54 -12
  218. svc_infra/security/oauth_models.py +73 -0
  219. svc_infra/security/org_invites.py +5 -3
  220. svc_infra/security/passwords.py +3 -1
  221. svc_infra/security/permissions.py +25 -2
  222. svc_infra/security/session.py +1 -1
  223. svc_infra/security/signed_cookies.py +21 -1
  224. svc_infra/storage/__init__.py +93 -0
  225. svc_infra/storage/add.py +253 -0
  226. svc_infra/storage/backends/__init__.py +11 -0
  227. svc_infra/storage/backends/local.py +339 -0
  228. svc_infra/storage/backends/memory.py +216 -0
  229. svc_infra/storage/backends/s3.py +353 -0
  230. svc_infra/storage/base.py +239 -0
  231. svc_infra/storage/easy.py +185 -0
  232. svc_infra/storage/settings.py +195 -0
  233. svc_infra/testing/__init__.py +685 -0
  234. svc_infra/utils.py +7 -3
  235. svc_infra/webhooks/__init__.py +69 -0
  236. svc_infra/webhooks/add.py +339 -0
  237. svc_infra/webhooks/encryption.py +115 -0
  238. svc_infra/webhooks/fastapi.py +39 -0
  239. svc_infra/webhooks/router.py +55 -0
  240. svc_infra/webhooks/service.py +70 -0
  241. svc_infra/webhooks/signing.py +34 -0
  242. svc_infra/websocket/__init__.py +79 -0
  243. svc_infra/websocket/add.py +140 -0
  244. svc_infra/websocket/client.py +282 -0
  245. svc_infra/websocket/config.py +69 -0
  246. svc_infra/websocket/easy.py +76 -0
  247. svc_infra/websocket/exceptions.py +61 -0
  248. svc_infra/websocket/manager.py +344 -0
  249. svc_infra/websocket/models.py +49 -0
  250. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  251. svc_infra-0.1.706.dist-info/METADATA +356 -0
  252. svc_infra-0.1.706.dist-info/RECORD +357 -0
  253. svc_infra-0.1.595.dist-info/METADATA +0 -80
  254. svc_infra-0.1.595.dist-info/RECORD +0 -253
  255. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  256. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,241 @@
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(
109
+ queue, tenant_id=tenant_id, metric=metric, day_start=day_start
110
+ )
111
+
112
+ return _tick
113
+
114
+
115
+ def make_billing_job_handler(
116
+ *,
117
+ session_factory: "async_sessionmaker[AsyncSession]",
118
+ webhooks: WebhookService,
119
+ ) -> Callable[[Job], Awaitable[None]]:
120
+ """Create a worker handler that processes billing jobs and emits webhooks.
121
+
122
+ Supported jobs and their expected payloads:
123
+ - billing.aggregate_daily {tenant_id, metric, day_start: ISO8601}
124
+ → emits topic 'billing.usage_aggregated'
125
+ - billing.generate_monthly_invoice {tenant_id, period_start: ISO8601, period_end: ISO8601, currency}
126
+ → emits topic 'billing.invoice.created'
127
+ """
128
+
129
+ async def _maybe_commit(session: Any) -> None:
130
+ """Commit if the session exposes a commit method (await if coroutine).
131
+
132
+ This makes the handler resilient in tests/dev where a dummy session is used.
133
+ """
134
+ commit = getattr(session, "commit", None)
135
+ if callable(commit):
136
+ result = commit()
137
+ if inspect.isawaitable(result):
138
+ await result
139
+
140
+ async def _handler(job: Job) -> None:
141
+ name = job.name
142
+ data: Dict[str, Any] = job.payload or {}
143
+ if name == BILLING_AGGREGATE_JOB:
144
+ tenant_id = str(data.get("tenant_id"))
145
+ metric = str(data.get("metric"))
146
+ day_raw = data.get("day_start")
147
+ if not tenant_id or not metric or not day_raw:
148
+ return
149
+ day_start = datetime.fromisoformat(str(day_raw))
150
+ async with session_factory() as session:
151
+ svc = AsyncBillingService(session=session, tenant_id=tenant_id)
152
+ total = await svc.aggregate_daily(metric=metric, day_start=day_start)
153
+ await _maybe_commit(session)
154
+ webhooks.publish(
155
+ "billing.usage_aggregated",
156
+ {
157
+ "tenant_id": tenant_id,
158
+ "metric": metric,
159
+ "day_start": day_start.astimezone(timezone.utc).isoformat(),
160
+ "total": int(total),
161
+ },
162
+ )
163
+ return
164
+ if name == BILLING_INVOICE_JOB:
165
+ tenant_id = str(data.get("tenant_id"))
166
+ period_start_raw = data.get("period_start")
167
+ period_end_raw = data.get("period_end")
168
+ currency = str(data.get("currency"))
169
+ if (
170
+ not tenant_id
171
+ or not period_start_raw
172
+ or not period_end_raw
173
+ or not currency
174
+ ):
175
+ return
176
+ period_start = datetime.fromisoformat(str(period_start_raw))
177
+ period_end = datetime.fromisoformat(str(period_end_raw))
178
+ async with session_factory() as session:
179
+ svc = AsyncBillingService(session=session, tenant_id=tenant_id)
180
+ invoice_id = await svc.generate_monthly_invoice(
181
+ period_start=period_start, period_end=period_end, currency=currency
182
+ )
183
+ await _maybe_commit(session)
184
+ webhooks.publish(
185
+ "billing.invoice.created",
186
+ {
187
+ "tenant_id": tenant_id,
188
+ "invoice_id": invoice_id,
189
+ "period_start": period_start.astimezone(timezone.utc).isoformat(),
190
+ "period_end": period_end.astimezone(timezone.utc).isoformat(),
191
+ "currency": currency,
192
+ },
193
+ )
194
+ return
195
+ # Ignore unrelated jobs
196
+
197
+ return _handler
198
+
199
+
200
+ def add_billing_jobs(
201
+ *,
202
+ scheduler: InMemoryScheduler,
203
+ queue: JobQueue,
204
+ jobs: list[dict],
205
+ ) -> None:
206
+ """Register simple interval-based billing job enqueuers.
207
+
208
+ jobs: list of dicts with shape {"name": "aggregate", "tenant_id": ..., "metric": ..., "interval_seconds": 86400}
209
+ or {"name": "invoice", "tenant_id": ..., "period_start": ISO, "period_end": ISO, "currency": ..., "interval_seconds": 2592000}
210
+ """
211
+
212
+ for j in jobs:
213
+ name = j.get("name")
214
+ interval = int(j.get("interval_seconds", 86400))
215
+ if name == "aggregate":
216
+ tenant_id = j["tenant_id"]
217
+ metric = j["metric"]
218
+
219
+ async def _tick_fn(tid=tenant_id, m=metric):
220
+ # Enqueue for the current UTC day
221
+ now = datetime.now(timezone.utc)
222
+ day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
223
+ enqueue_aggregate_daily(
224
+ queue, tenant_id=tid, metric=m, day_start=day_start
225
+ )
226
+
227
+ scheduler.add_task(
228
+ f"billing.aggregate.{tenant_id}.{metric}", interval, _tick_fn
229
+ )
230
+ elif name == "invoice":
231
+ tenant_id = j["tenant_id"]
232
+ currency = j["currency"]
233
+ pstart = datetime.fromisoformat(j["period_start"]).astimezone(timezone.utc)
234
+ pend = datetime.fromisoformat(j["period_end"]).astimezone(timezone.utc)
235
+
236
+ async def _tick_inv(tid=tenant_id, cs=currency, ps=pstart, pe=pend):
237
+ enqueue_generate_monthly_invoice(
238
+ queue, tenant_id=tid, period_start=ps, period_end=pe, currency=cs
239
+ )
240
+
241
+ scheduler.add_task(f"billing.invoice.{tenant_id}", interval, _tick_inv)
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Optional
5
+
6
+ from sqlalchemy import JSON, DateTime, Index, Numeric, String, UniqueConstraint, text
7
+ from sqlalchemy.orm import Mapped, mapped_column
8
+
9
+ from svc_infra.db.sql.base import ModelBase
10
+
11
+ TENANT_ID_LEN = 64
12
+
13
+
14
+ class UsageEvent(ModelBase):
15
+ __tablename__ = "billing_usage_events"
16
+
17
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
18
+ tenant_id: Mapped[str] = mapped_column(
19
+ String(TENANT_ID_LEN), index=True, nullable=False
20
+ )
21
+ metric: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
22
+ amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
23
+ at_ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
24
+ idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False)
25
+ metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
26
+ created_at: Mapped[datetime] = mapped_column(
27
+ DateTime(timezone=True),
28
+ server_default=text("CURRENT_TIMESTAMP"),
29
+ nullable=False,
30
+ )
31
+
32
+ __table_args__ = (
33
+ UniqueConstraint(
34
+ "tenant_id", "metric", "idempotency_key", name="uq_usage_idem"
35
+ ),
36
+ Index("ix_usage_tenant_metric_ts", "tenant_id", "metric", "at_ts"),
37
+ )
38
+
39
+
40
+ class UsageAggregate(ModelBase):
41
+ __tablename__ = "billing_usage_aggregates"
42
+
43
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
44
+ tenant_id: Mapped[str] = mapped_column(
45
+ String(TENANT_ID_LEN), index=True, nullable=False
46
+ )
47
+ metric: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
48
+ period_start: Mapped[datetime] = mapped_column(
49
+ DateTime(timezone=True), nullable=False
50
+ )
51
+ granularity: Mapped[str] = mapped_column(
52
+ String(8), nullable=False
53
+ ) # hour|day|month
54
+ total: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
55
+ updated_at: Mapped[datetime] = mapped_column(
56
+ DateTime(timezone=True),
57
+ server_default=text("CURRENT_TIMESTAMP"),
58
+ nullable=False,
59
+ )
60
+
61
+ __table_args__ = (
62
+ UniqueConstraint(
63
+ "tenant_id", "metric", "period_start", "granularity", name="uq_usage_agg"
64
+ ),
65
+ )
66
+
67
+
68
+ class Plan(ModelBase):
69
+ __tablename__ = "billing_plans"
70
+
71
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
72
+ key: Mapped[str] = mapped_column(
73
+ String(64), unique=True, index=True, nullable=False
74
+ )
75
+ name: Mapped[str] = mapped_column(String(128), nullable=False)
76
+ description: Mapped[Optional[str]] = mapped_column(String(255))
77
+ created_at: Mapped[datetime] = mapped_column(
78
+ DateTime(timezone=True),
79
+ server_default=text("CURRENT_TIMESTAMP"),
80
+ nullable=False,
81
+ )
82
+
83
+
84
+ class PlanEntitlement(ModelBase):
85
+ __tablename__ = "billing_plan_entitlements"
86
+
87
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
88
+ plan_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
89
+ key: Mapped[str] = mapped_column(String(64), nullable=False)
90
+ limit_per_window: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
91
+ window: Mapped[str] = mapped_column(String(8), nullable=False) # day|month
92
+ created_at: Mapped[datetime] = mapped_column(
93
+ DateTime(timezone=True),
94
+ server_default=text("CURRENT_TIMESTAMP"),
95
+ nullable=False,
96
+ )
97
+
98
+
99
+ class Subscription(ModelBase):
100
+ __tablename__ = "billing_subscriptions"
101
+
102
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
103
+ tenant_id: Mapped[str] = mapped_column(
104
+ String(TENANT_ID_LEN), index=True, nullable=False
105
+ )
106
+ plan_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
107
+ effective_at: Mapped[datetime] = mapped_column(
108
+ DateTime(timezone=True), nullable=False
109
+ )
110
+ ended_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
111
+ created_at: Mapped[datetime] = mapped_column(
112
+ DateTime(timezone=True),
113
+ server_default=text("CURRENT_TIMESTAMP"),
114
+ nullable=False,
115
+ )
116
+
117
+
118
+ class Price(ModelBase):
119
+ __tablename__ = "billing_prices"
120
+
121
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
122
+ key: Mapped[str] = mapped_column(
123
+ String(64), unique=True, index=True, nullable=False
124
+ )
125
+ currency: Mapped[str] = mapped_column(String(8), nullable=False)
126
+ unit_amount: Mapped[int] = mapped_column(
127
+ Numeric(18, 0), nullable=False
128
+ ) # minor units
129
+ metric: Mapped[Optional[str]] = mapped_column(
130
+ String(64)
131
+ ) # null for fixed recurring
132
+ recurring_interval: Mapped[Optional[str]] = mapped_column(String(8)) # month|year
133
+ created_at: Mapped[datetime] = mapped_column(
134
+ DateTime(timezone=True),
135
+ server_default=text("CURRENT_TIMESTAMP"),
136
+ nullable=False,
137
+ )
138
+
139
+
140
+ class Invoice(ModelBase):
141
+ __tablename__ = "billing_invoices"
142
+
143
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
144
+ tenant_id: Mapped[str] = mapped_column(
145
+ String(TENANT_ID_LEN), index=True, nullable=False
146
+ )
147
+ period_start: Mapped[datetime] = mapped_column(
148
+ DateTime(timezone=True), nullable=False
149
+ )
150
+ period_end: Mapped[datetime] = mapped_column(
151
+ DateTime(timezone=True), nullable=False
152
+ )
153
+ status: Mapped[str] = mapped_column(String(16), index=True, nullable=False)
154
+ total_amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
155
+ currency: Mapped[str] = mapped_column(String(8), nullable=False)
156
+ provider_invoice_id: Mapped[Optional[str]] = mapped_column(String(128), index=True)
157
+ created_at: Mapped[datetime] = mapped_column(
158
+ DateTime(timezone=True),
159
+ server_default=text("CURRENT_TIMESTAMP"),
160
+ nullable=False,
161
+ )
162
+
163
+
164
+ class InvoiceLine(ModelBase):
165
+ __tablename__ = "billing_invoice_lines"
166
+
167
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
168
+ invoice_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
169
+ price_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
170
+ metric: Mapped[Optional[str]] = mapped_column(String(64))
171
+ quantity: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
172
+ amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
173
+ created_at: Mapped[datetime] = mapped_column(
174
+ DateTime(timezone=True),
175
+ server_default=text("CURRENT_TIMESTAMP"),
176
+ nullable=False,
177
+ )
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Annotated, Optional
5
+
6
+ from fastapi import Depends, HTTPException, status
7
+ from sqlalchemy import select
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
11
+ from svc_infra.api.fastapi.tenancy.context import TenantId
12
+
13
+ from .models import PlanEntitlement, Subscription, UsageAggregate
14
+
15
+
16
+ async def _current_subscription(
17
+ session: AsyncSession, tenant_id: str
18
+ ) -> Optional[Subscription]:
19
+ now = datetime.now(tz=timezone.utc)
20
+ row = (
21
+ (
22
+ await session.execute(
23
+ select(Subscription)
24
+ .where(Subscription.tenant_id == tenant_id)
25
+ .order_by(Subscription.effective_at.desc())
26
+ )
27
+ )
28
+ .scalars()
29
+ .first()
30
+ )
31
+ if row is None:
32
+ return None
33
+ # basic check: if ended_at is set and in the past, treat as inactive
34
+ if row.ended_at is not None and row.ended_at <= now:
35
+ return None
36
+ return row
37
+
38
+
39
+ def require_quota(metric: str, *, window: str = "day", soft: bool = True):
40
+ async def _dep(tenant_id: TenantId, session: SqlSessionDep) -> None:
41
+ sub = await _current_subscription(session, tenant_id)
42
+ if sub is None:
43
+ # no subscription → allow (unlimited) by default
44
+ return
45
+ ent = (
46
+ (
47
+ await session.execute(
48
+ select(PlanEntitlement).where(
49
+ PlanEntitlement.plan_id == sub.plan_id,
50
+ PlanEntitlement.key == metric,
51
+ PlanEntitlement.window == window,
52
+ )
53
+ )
54
+ )
55
+ .scalars()
56
+ .first()
57
+ )
58
+ if ent is None:
59
+ # no entitlement → unlimited
60
+ return
61
+ # compute current window start
62
+ now = datetime.now(tz=timezone.utc)
63
+ if window == "day":
64
+ period_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
65
+ granularity = "day"
66
+ elif window == "month":
67
+ period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
68
+ granularity = "month" # we only aggregate per day, but future-proof
69
+ else:
70
+ period_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
71
+ granularity = "day"
72
+
73
+ used_row = (
74
+ (
75
+ await session.execute(
76
+ select(UsageAggregate).where(
77
+ UsageAggregate.tenant_id == tenant_id,
78
+ UsageAggregate.metric == metric,
79
+ UsageAggregate.granularity == granularity, # v1 daily baseline
80
+ UsageAggregate.period_start == period_start,
81
+ )
82
+ )
83
+ )
84
+ .scalars()
85
+ .first()
86
+ )
87
+ used = int(used_row.total) if used_row else 0
88
+ limit_ = int(ent.limit_per_window)
89
+ if used >= limit_:
90
+ if soft:
91
+ # allow but signal overage via header later (TODO: add header hook)
92
+ return
93
+ raise HTTPException(
94
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
95
+ detail=f"Quota exceeded for {metric} in {window} window",
96
+ )
97
+
98
+ return _dep
99
+
100
+
101
+ QuotaDep = Annotated[None, Depends(require_quota)]
102
+
103
+ __all__ = ["require_quota"]
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Annotated, Optional
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class UsageIn(BaseModel):
10
+ metric: str = Field(..., min_length=1, max_length=64)
11
+ amount: Annotated[
12
+ int, Field(ge=0, description="Non-negative amount for the metric")
13
+ ]
14
+ at: Optional[datetime] = Field(
15
+ default=None,
16
+ description="Event timestamp (UTC). Defaults to server time if omitted.",
17
+ )
18
+ idempotency_key: str = Field(..., min_length=1, max_length=128)
19
+ metadata: dict = Field(default_factory=dict)
20
+
21
+
22
+ class UsageAckOut(BaseModel):
23
+ id: str
24
+ accepted: bool = True
25
+
26
+
27
+ class UsageAggregateRow(BaseModel):
28
+ period_start: datetime
29
+ granularity: str
30
+ metric: str
31
+ total: int
32
+
33
+
34
+ class UsageAggregatesOut(BaseModel):
35
+ items: list[UsageAggregateRow] = Field(default_factory=list)
36
+ next_cursor: Optional[str] = None
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Callable, Optional
7
+
8
+ from sqlalchemy import select
9
+ from sqlalchemy.orm import Session
10
+
11
+ from .models import Invoice, InvoiceLine, UsageAggregate, UsageEvent
12
+
13
+ ProviderSyncHook = Callable[[Invoice, list[InvoiceLine]], None]
14
+
15
+
16
+ @dataclass
17
+ class BillingService:
18
+ session: Session
19
+ tenant_id: str
20
+ provider_sync: Optional[ProviderSyncHook] = None
21
+
22
+ def record_usage(
23
+ self,
24
+ *,
25
+ metric: str,
26
+ amount: int,
27
+ at: datetime,
28
+ idempotency_key: str,
29
+ metadata: dict | None,
30
+ ) -> str:
31
+ # Ensure UTC
32
+ if at.tzinfo is None:
33
+ at = at.replace(tzinfo=timezone.utc)
34
+ evt = UsageEvent(
35
+ id=str(uuid.uuid4()),
36
+ tenant_id=self.tenant_id,
37
+ metric=metric,
38
+ amount=amount,
39
+ at_ts=at,
40
+ idempotency_key=idempotency_key,
41
+ metadata_json=metadata or {},
42
+ )
43
+ self.session.add(evt)
44
+ self.session.flush()
45
+ return evt.id
46
+
47
+ def aggregate_daily(self, *, metric: str, day_start: datetime) -> None:
48
+ # Compute [day_start, day_start+1d)
49
+ next_day = day_start.replace(
50
+ hour=0, minute=0, second=0, microsecond=0
51
+ ) + timedelta(days=1)
52
+ total = 0
53
+ rows = self.session.execute(
54
+ select(UsageEvent).where(
55
+ UsageEvent.tenant_id == self.tenant_id,
56
+ UsageEvent.metric == metric,
57
+ UsageEvent.at_ts >= day_start,
58
+ UsageEvent.at_ts < next_day,
59
+ )
60
+ ).scalars()
61
+ for r in rows:
62
+ total += int(r.amount)
63
+ # upsert aggregate
64
+ agg = self.session.execute(
65
+ select(UsageAggregate).where(
66
+ UsageAggregate.tenant_id == self.tenant_id,
67
+ UsageAggregate.metric == metric,
68
+ UsageAggregate.period_start == day_start,
69
+ UsageAggregate.granularity == "day",
70
+ )
71
+ ).scalar_one_or_none()
72
+ if agg:
73
+ agg.total = total
74
+ else:
75
+ self.session.add(
76
+ UsageAggregate(
77
+ id=str(uuid.uuid4()),
78
+ tenant_id=self.tenant_id,
79
+ metric=metric,
80
+ period_start=day_start,
81
+ granularity="day",
82
+ total=total,
83
+ )
84
+ )
85
+
86
+ def generate_monthly_invoice(
87
+ self, *, period_start: datetime, period_end: datetime, currency: str
88
+ ) -> str:
89
+ # Minimal: sum all daily aggregates and produce one line
90
+ total = 0
91
+ rows = self.session.execute(
92
+ select(UsageAggregate).where(
93
+ UsageAggregate.tenant_id == self.tenant_id,
94
+ UsageAggregate.period_start >= period_start,
95
+ UsageAggregate.period_start < period_end,
96
+ UsageAggregate.granularity == "day",
97
+ )
98
+ ).scalars()
99
+ for r in rows:
100
+ total += int(r.total)
101
+ inv = Invoice(
102
+ id=str(uuid.uuid4()),
103
+ tenant_id=self.tenant_id,
104
+ period_start=period_start,
105
+ period_end=period_end,
106
+ status="created",
107
+ total_amount=total,
108
+ currency=currency,
109
+ )
110
+ self.session.add(inv)
111
+ self.session.flush()
112
+ line = InvoiceLine(
113
+ id=str(uuid.uuid4()),
114
+ invoice_id=inv.id,
115
+ price_id=None,
116
+ metric=None,
117
+ quantity=1,
118
+ amount=total,
119
+ )
120
+ self.session.add(line)
121
+ if self.provider_sync:
122
+ self.provider_sync(inv, [line])
123
+ return inv.id
@@ -0,0 +1,5 @@
1
+ # Bundled Docs
2
+
3
+ This directory contains a minimal set of Markdown files that the `svc-infra docs` CLI can fall back to when the project running the CLI doesn't have a local `docs/` directory.
4
+
5
+ You can add more topics here as needed; each `*.md` file becomes a topic named after its stem (e.g., `getting-started.md` -> `getting-started`).
@@ -0,0 +1 @@
1
+ # Bundled docs package for zip-safe importlib.resources access
@@ -0,0 +1,6 @@
1
+ # Getting Started
2
+
3
+ Welcome to svc-infra docs. Use `svc-infra docs list` to see topics.
4
+
5
+ - This content is bundled with the package.
6
+ - If your project doesn't have a local `docs/` folder, you'll still see this.