svc-infra 0.1.600__py3-none-any.whl → 0.1.640__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.
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/db/sql/add.py +32 -13
- svc_infra/api/fastapi/db/sql/crud_router.py +178 -16
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/dependencies/ratelimit.py +57 -7
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +59 -1
- svc_infra/api/fastapi/middleware/ratelimit_store.py +12 -6
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +114 -0
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +3 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +11 -1
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/app/README.md +5 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +28 -8
- svc_infra/cli/cmds/__init__.py +8 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/sql/repository.py +51 -11
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +9 -1
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +9 -2
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/docs/acceptance-matrix.md +71 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/api.md +59 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/webhook_delivery.py +14 -2
- svc_infra/jobs/queue.py +9 -1
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/worker.py +17 -1
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +6 -2
- svc_infra/security/permissions.py +1 -0
- svc_infra/webhooks/service.py +10 -2
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.640.dist-info}/METADATA +40 -14
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.640.dist-info}/RECORD +118 -44
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.640.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.640.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
|