svc-infra 0.1.562__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 (175) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/models.py +142 -4
  3. svc_infra/apf_payments/provider/__init__.py +4 -0
  4. svc_infra/apf_payments/provider/aiydan.py +797 -0
  5. svc_infra/apf_payments/provider/base.py +178 -12
  6. svc_infra/apf_payments/provider/stripe.py +757 -48
  7. svc_infra/apf_payments/schemas.py +163 -1
  8. svc_infra/apf_payments/service.py +582 -42
  9. svc_infra/apf_payments/settings.py +22 -2
  10. svc_infra/api/fastapi/admin/__init__.py +3 -0
  11. svc_infra/api/fastapi/admin/add.py +231 -0
  12. svc_infra/api/fastapi/apf_payments/router.py +792 -73
  13. svc_infra/api/fastapi/apf_payments/setup.py +13 -4
  14. svc_infra/api/fastapi/auth/add.py +10 -4
  15. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  16. svc_infra/api/fastapi/auth/routers/oauth_router.py +74 -34
  17. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  18. svc_infra/api/fastapi/auth/settings.py +2 -0
  19. svc_infra/api/fastapi/billing/router.py +64 -0
  20. svc_infra/api/fastapi/billing/setup.py +19 -0
  21. svc_infra/api/fastapi/cache/add.py +9 -5
  22. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  23. svc_infra/api/fastapi/db/sql/add.py +40 -18
  24. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  25. svc_infra/api/fastapi/db/sql/session.py +16 -0
  26. svc_infra/api/fastapi/db/sql/users.py +13 -1
  27. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  28. svc_infra/api/fastapi/docs/add.py +160 -0
  29. svc_infra/api/fastapi/docs/landing.py +1 -1
  30. svc_infra/api/fastapi/docs/scoped.py +41 -6
  31. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  32. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  33. svc_infra/api/fastapi/middleware/idempotency.py +82 -42
  34. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  35. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  36. svc_infra/api/fastapi/middleware/ratelimit.py +84 -11
  37. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  38. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  39. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  40. svc_infra/api/fastapi/openapi/mutators.py +244 -38
  41. svc_infra/api/fastapi/ops/add.py +73 -0
  42. svc_infra/api/fastapi/pagination.py +133 -32
  43. svc_infra/api/fastapi/routers/ping.py +1 -0
  44. svc_infra/api/fastapi/setup.py +23 -14
  45. svc_infra/api/fastapi/tenancy/add.py +19 -0
  46. svc_infra/api/fastapi/tenancy/context.py +112 -0
  47. svc_infra/api/fastapi/versioned.py +101 -0
  48. svc_infra/app/README.md +5 -5
  49. svc_infra/billing/__init__.py +23 -0
  50. svc_infra/billing/async_service.py +147 -0
  51. svc_infra/billing/jobs.py +230 -0
  52. svc_infra/billing/models.py +131 -0
  53. svc_infra/billing/quotas.py +101 -0
  54. svc_infra/billing/schemas.py +33 -0
  55. svc_infra/billing/service.py +115 -0
  56. svc_infra/bundled_docs/README.md +5 -0
  57. svc_infra/bundled_docs/__init__.py +1 -0
  58. svc_infra/bundled_docs/getting-started.md +6 -0
  59. svc_infra/cache/__init__.py +4 -0
  60. svc_infra/cache/add.py +158 -0
  61. svc_infra/cache/backend.py +5 -2
  62. svc_infra/cache/decorators.py +19 -1
  63. svc_infra/cache/keys.py +24 -4
  64. svc_infra/cli/__init__.py +32 -8
  65. svc_infra/cli/__main__.py +4 -0
  66. svc_infra/cli/cmds/__init__.py +10 -0
  67. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  68. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  69. svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
  70. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  71. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  72. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  73. svc_infra/cli/cmds/dx/__init__.py +12 -0
  74. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  75. svc_infra/cli/cmds/help.py +4 -0
  76. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  77. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  78. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  79. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  80. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  81. svc_infra/data/add.py +61 -0
  82. svc_infra/data/backup.py +53 -0
  83. svc_infra/data/erasure.py +45 -0
  84. svc_infra/data/fixtures.py +40 -0
  85. svc_infra/data/retention.py +55 -0
  86. svc_infra/db/inbox.py +67 -0
  87. svc_infra/db/nosql/mongo/README.md +13 -13
  88. svc_infra/db/outbox.py +104 -0
  89. svc_infra/db/sql/repository.py +52 -12
  90. svc_infra/db/sql/resource.py +5 -0
  91. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  92. svc_infra/db/sql/templates/setup/env_async.py.tmpl +13 -8
  93. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +9 -5
  94. svc_infra/db/sql/tenant.py +79 -0
  95. svc_infra/db/sql/utils.py +18 -4
  96. svc_infra/db/sql/versioning.py +14 -0
  97. svc_infra/docs/acceptance-matrix.md +71 -0
  98. svc_infra/docs/acceptance.md +44 -0
  99. svc_infra/docs/admin.md +425 -0
  100. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  101. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  102. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  103. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  104. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  105. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  106. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  107. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  108. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  109. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  110. svc_infra/docs/api.md +59 -0
  111. svc_infra/docs/auth.md +11 -0
  112. svc_infra/docs/billing.md +190 -0
  113. svc_infra/docs/cache.md +76 -0
  114. svc_infra/docs/cli.md +74 -0
  115. svc_infra/docs/contributing.md +34 -0
  116. svc_infra/docs/data-lifecycle.md +52 -0
  117. svc_infra/docs/database.md +14 -0
  118. svc_infra/docs/docs-and-sdks.md +62 -0
  119. svc_infra/docs/environment.md +114 -0
  120. svc_infra/docs/getting-started.md +63 -0
  121. svc_infra/docs/idempotency.md +111 -0
  122. svc_infra/docs/jobs.md +67 -0
  123. svc_infra/docs/observability.md +16 -0
  124. svc_infra/docs/ops.md +37 -0
  125. svc_infra/docs/rate-limiting.md +125 -0
  126. svc_infra/docs/repo-review.md +48 -0
  127. svc_infra/docs/security.md +176 -0
  128. svc_infra/docs/tenancy.md +35 -0
  129. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  130. svc_infra/docs/versioned-integrations.md +146 -0
  131. svc_infra/docs/webhooks.md +112 -0
  132. svc_infra/dx/add.py +63 -0
  133. svc_infra/dx/changelog.py +74 -0
  134. svc_infra/dx/checks.py +67 -0
  135. svc_infra/http/__init__.py +13 -0
  136. svc_infra/http/client.py +72 -0
  137. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  138. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  139. svc_infra/jobs/easy.py +32 -0
  140. svc_infra/jobs/loader.py +45 -0
  141. svc_infra/jobs/queue.py +81 -0
  142. svc_infra/jobs/redis_queue.py +191 -0
  143. svc_infra/jobs/runner.py +75 -0
  144. svc_infra/jobs/scheduler.py +41 -0
  145. svc_infra/jobs/worker.py +40 -0
  146. svc_infra/mcp/svc_infra_mcp.py +85 -28
  147. svc_infra/obs/README.md +2 -0
  148. svc_infra/obs/add.py +54 -7
  149. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  150. svc_infra/obs/metrics/__init__.py +53 -0
  151. svc_infra/obs/metrics.py +52 -0
  152. svc_infra/security/add.py +201 -0
  153. svc_infra/security/audit.py +130 -0
  154. svc_infra/security/audit_service.py +73 -0
  155. svc_infra/security/headers.py +52 -0
  156. svc_infra/security/hibp.py +95 -0
  157. svc_infra/security/jwt_rotation.py +53 -0
  158. svc_infra/security/lockout.py +96 -0
  159. svc_infra/security/models.py +255 -0
  160. svc_infra/security/org_invites.py +128 -0
  161. svc_infra/security/passwords.py +77 -0
  162. svc_infra/security/permissions.py +149 -0
  163. svc_infra/security/session.py +98 -0
  164. svc_infra/security/signed_cookies.py +80 -0
  165. svc_infra/webhooks/__init__.py +16 -0
  166. svc_infra/webhooks/add.py +322 -0
  167. svc_infra/webhooks/fastapi.py +37 -0
  168. svc_infra/webhooks/router.py +55 -0
  169. svc_infra/webhooks/service.py +67 -0
  170. svc_infra/webhooks/signing.py +30 -0
  171. svc_infra-0.1.654.dist-info/METADATA +154 -0
  172. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/RECORD +174 -56
  173. svc_infra-0.1.562.dist-info/METADATA +0 -79
  174. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  175. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
@@ -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)
@@ -0,0 +1,131 @@
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(String(TENANT_ID_LEN), index=True, nullable=False)
19
+ metric: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
20
+ amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
21
+ at_ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
22
+ idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False)
23
+ metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
24
+ created_at: Mapped[datetime] = mapped_column(
25
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
26
+ )
27
+
28
+ __table_args__ = (
29
+ UniqueConstraint("tenant_id", "metric", "idempotency_key", name="uq_usage_idem"),
30
+ Index("ix_usage_tenant_metric_ts", "tenant_id", "metric", "at_ts"),
31
+ )
32
+
33
+
34
+ class UsageAggregate(ModelBase):
35
+ __tablename__ = "billing_usage_aggregates"
36
+
37
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
38
+ tenant_id: Mapped[str] = mapped_column(String(TENANT_ID_LEN), index=True, nullable=False)
39
+ metric: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
40
+ period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
41
+ granularity: Mapped[str] = mapped_column(String(8), nullable=False) # hour|day|month
42
+ total: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
43
+ updated_at: Mapped[datetime] = mapped_column(
44
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
45
+ )
46
+
47
+ __table_args__ = (
48
+ UniqueConstraint("tenant_id", "metric", "period_start", "granularity", name="uq_usage_agg"),
49
+ )
50
+
51
+
52
+ class Plan(ModelBase):
53
+ __tablename__ = "billing_plans"
54
+
55
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
56
+ key: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
57
+ name: Mapped[str] = mapped_column(String(128), nullable=False)
58
+ description: Mapped[Optional[str]] = mapped_column(String(255))
59
+ created_at: Mapped[datetime] = mapped_column(
60
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
61
+ )
62
+
63
+
64
+ class PlanEntitlement(ModelBase):
65
+ __tablename__ = "billing_plan_entitlements"
66
+
67
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
68
+ plan_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
69
+ key: Mapped[str] = mapped_column(String(64), nullable=False)
70
+ limit_per_window: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
71
+ window: Mapped[str] = mapped_column(String(8), nullable=False) # day|month
72
+ created_at: Mapped[datetime] = mapped_column(
73
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
74
+ )
75
+
76
+
77
+ class Subscription(ModelBase):
78
+ __tablename__ = "billing_subscriptions"
79
+
80
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
81
+ tenant_id: Mapped[str] = mapped_column(String(TENANT_ID_LEN), index=True, nullable=False)
82
+ plan_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
83
+ effective_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
84
+ ended_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
85
+ created_at: Mapped[datetime] = mapped_column(
86
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
87
+ )
88
+
89
+
90
+ class Price(ModelBase):
91
+ __tablename__ = "billing_prices"
92
+
93
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
94
+ key: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
95
+ currency: Mapped[str] = mapped_column(String(8), nullable=False)
96
+ unit_amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False) # minor units
97
+ metric: Mapped[Optional[str]] = mapped_column(String(64)) # null for fixed recurring
98
+ recurring_interval: Mapped[Optional[str]] = mapped_column(String(8)) # month|year
99
+ created_at: Mapped[datetime] = mapped_column(
100
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
101
+ )
102
+
103
+
104
+ class Invoice(ModelBase):
105
+ __tablename__ = "billing_invoices"
106
+
107
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
108
+ tenant_id: Mapped[str] = mapped_column(String(TENANT_ID_LEN), index=True, nullable=False)
109
+ period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
110
+ period_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
111
+ status: Mapped[str] = mapped_column(String(16), index=True, nullable=False)
112
+ total_amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
113
+ currency: Mapped[str] = mapped_column(String(8), nullable=False)
114
+ provider_invoice_id: Mapped[Optional[str]] = mapped_column(String(128), index=True)
115
+ created_at: Mapped[datetime] = mapped_column(
116
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
117
+ )
118
+
119
+
120
+ class InvoiceLine(ModelBase):
121
+ __tablename__ = "billing_invoice_lines"
122
+
123
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
124
+ invoice_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
125
+ price_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
126
+ metric: Mapped[Optional[str]] = mapped_column(String(64))
127
+ quantity: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
128
+ amount: Mapped[int] = mapped_column(Numeric(18, 0), nullable=False)
129
+ created_at: Mapped[datetime] = mapped_column(
130
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
131
+ )
@@ -0,0 +1,101 @@
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(session: AsyncSession, tenant_id: str) -> Optional[Subscription]:
17
+ now = datetime.now(tz=timezone.utc)
18
+ row = (
19
+ (
20
+ await session.execute(
21
+ select(Subscription)
22
+ .where(Subscription.tenant_id == tenant_id)
23
+ .order_by(Subscription.effective_at.desc())
24
+ )
25
+ )
26
+ .scalars()
27
+ .first()
28
+ )
29
+ if row is None:
30
+ return None
31
+ # basic check: if ended_at is set and in the past, treat as inactive
32
+ if row.ended_at is not None and row.ended_at <= now:
33
+ return None
34
+ return row
35
+
36
+
37
+ def require_quota(metric: str, *, window: str = "day", soft: bool = True):
38
+ async def _dep(tenant_id: TenantId, session: SqlSessionDep) -> None:
39
+ sub = await _current_subscription(session, tenant_id)
40
+ if sub is None:
41
+ # no subscription → allow (unlimited) by default
42
+ return
43
+ ent = (
44
+ (
45
+ await session.execute(
46
+ select(PlanEntitlement).where(
47
+ PlanEntitlement.plan_id == sub.plan_id,
48
+ PlanEntitlement.key == metric,
49
+ PlanEntitlement.window == window,
50
+ )
51
+ )
52
+ )
53
+ .scalars()
54
+ .first()
55
+ )
56
+ if ent is None:
57
+ # no entitlement → unlimited
58
+ return
59
+ # compute current window start
60
+ now = datetime.now(tz=timezone.utc)
61
+ if window == "day":
62
+ period_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
63
+ granularity = "day"
64
+ elif window == "month":
65
+ period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
66
+ granularity = "month" # we only aggregate per day, but future-proof
67
+ else:
68
+ period_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
69
+ granularity = "day"
70
+
71
+ used_row = (
72
+ (
73
+ await session.execute(
74
+ select(UsageAggregate).where(
75
+ UsageAggregate.tenant_id == tenant_id,
76
+ UsageAggregate.metric == metric,
77
+ UsageAggregate.granularity == granularity, # v1 daily baseline
78
+ UsageAggregate.period_start == period_start,
79
+ )
80
+ )
81
+ )
82
+ .scalars()
83
+ .first()
84
+ )
85
+ used = int(used_row.total) if used_row else 0
86
+ limit_ = int(ent.limit_per_window)
87
+ if used >= limit_:
88
+ if soft:
89
+ # allow but signal overage via header later (TODO: add header hook)
90
+ return
91
+ raise HTTPException(
92
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
93
+ detail=f"Quota exceeded for {metric} in {window} window",
94
+ )
95
+
96
+ return _dep
97
+
98
+
99
+ QuotaDep = Annotated[None, Depends(require_quota)]
100
+
101
+ __all__ = ["require_quota"]
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Optional
5
+
6
+ from pydantic import BaseModel, Field, conint
7
+
8
+
9
+ class UsageIn(BaseModel):
10
+ metric: str = Field(..., min_length=1, max_length=64)
11
+ amount: conint(ge=0) = Field(..., description="Non-negative amount for the metric")
12
+ at: Optional[datetime] = Field(
13
+ default=None, description="Event timestamp (UTC). Defaults to server time if omitted."
14
+ )
15
+ idempotency_key: str = Field(..., min_length=1, max_length=128)
16
+ metadata: dict = Field(default_factory=dict)
17
+
18
+
19
+ class UsageAckOut(BaseModel):
20
+ id: str
21
+ accepted: bool = True
22
+
23
+
24
+ class UsageAggregateRow(BaseModel):
25
+ period_start: datetime
26
+ granularity: str
27
+ metric: str
28
+ total: int
29
+
30
+
31
+ class UsageAggregatesOut(BaseModel):
32
+ items: list[UsageAggregateRow] = Field(default_factory=list)
33
+ next_cursor: Optional[str] = None