svc-infra 0.1.629__py3-none-any.whl → 0.1.630__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/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/middleware/errors/handlers.py +15 -0
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/ratelimit_store.py +12 -6
- svc_infra/api/fastapi/middleware/timeout.py +144 -0
- svc_infra/api/fastapi/setup.py +10 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +218 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/docs/adr/0008-billing-primitives.md +34 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/ops.md +4 -0
- svc_infra/docs/rate-limiting.md +4 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +64 -0
- svc_infra/jobs/builtins/webhook_delivery.py +14 -2
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/worker.py +17 -1
- svc_infra/security/hibp.py +6 -2
- {svc_infra-0.1.629.dist-info → svc_infra-0.1.630.dist-info}/METADATA +1 -1
- {svc_infra-0.1.629.dist-info → svc_infra-0.1.630.dist-info}/RECORD +28 -14
- {svc_infra-0.1.629.dist-info → svc_infra-0.1.630.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.629.dist-info → svc_infra-0.1.630.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any, Awaitable, Callable, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
7
|
+
|
|
8
|
+
from svc_infra.jobs.queue import Job, JobQueue
|
|
9
|
+
from svc_infra.jobs.scheduler import InMemoryScheduler
|
|
10
|
+
from svc_infra.webhooks.service import WebhookService
|
|
11
|
+
|
|
12
|
+
from .async_service import AsyncBillingService
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def job_aggregate_daily(
|
|
16
|
+
session: AsyncSession, *, tenant_id: str, metric: str, day_start: datetime
|
|
17
|
+
) -> None:
|
|
18
|
+
"""
|
|
19
|
+
Aggregate usage for a tenant/metric for the given day_start (UTC).
|
|
20
|
+
|
|
21
|
+
Intended to be called from a scheduler/worker with an AsyncSession created by the host app.
|
|
22
|
+
"""
|
|
23
|
+
svc = AsyncBillingService(session=session, tenant_id=tenant_id)
|
|
24
|
+
if day_start.tzinfo is None:
|
|
25
|
+
day_start = day_start.replace(tzinfo=timezone.utc)
|
|
26
|
+
await svc.aggregate_daily(metric=metric, day_start=day_start)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def job_generate_monthly_invoice(
|
|
30
|
+
session: AsyncSession,
|
|
31
|
+
*,
|
|
32
|
+
tenant_id: str,
|
|
33
|
+
period_start: datetime,
|
|
34
|
+
period_end: datetime,
|
|
35
|
+
currency: str,
|
|
36
|
+
) -> str:
|
|
37
|
+
"""
|
|
38
|
+
Generate a monthly invoice for a tenant between [period_start, period_end).
|
|
39
|
+
Returns the internal invoice id.
|
|
40
|
+
"""
|
|
41
|
+
svc = AsyncBillingService(session=session, tenant_id=tenant_id)
|
|
42
|
+
if period_start.tzinfo is None:
|
|
43
|
+
period_start = period_start.replace(tzinfo=timezone.utc)
|
|
44
|
+
if period_end.tzinfo is None:
|
|
45
|
+
period_end = period_end.replace(tzinfo=timezone.utc)
|
|
46
|
+
return await svc.generate_monthly_invoice(
|
|
47
|
+
period_start=period_start, period_end=period_end, currency=currency
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# -------- Job helpers and handlers (scheduler/worker wiring) ---------
|
|
52
|
+
|
|
53
|
+
BILLING_AGGREGATE_JOB = "billing.aggregate_daily"
|
|
54
|
+
BILLING_INVOICE_JOB = "billing.generate_monthly_invoice"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def enqueue_aggregate_daily(
|
|
58
|
+
queue: JobQueue,
|
|
59
|
+
*,
|
|
60
|
+
tenant_id: str,
|
|
61
|
+
metric: str,
|
|
62
|
+
day_start: datetime,
|
|
63
|
+
delay_seconds: int = 0,
|
|
64
|
+
) -> None:
|
|
65
|
+
payload = {
|
|
66
|
+
"tenant_id": tenant_id,
|
|
67
|
+
"metric": metric,
|
|
68
|
+
"day_start": day_start.astimezone(timezone.utc).isoformat(),
|
|
69
|
+
}
|
|
70
|
+
queue.enqueue(BILLING_AGGREGATE_JOB, payload, delay_seconds=delay_seconds)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def enqueue_generate_monthly_invoice(
|
|
74
|
+
queue: JobQueue,
|
|
75
|
+
*,
|
|
76
|
+
tenant_id: str,
|
|
77
|
+
period_start: datetime,
|
|
78
|
+
period_end: datetime,
|
|
79
|
+
currency: str,
|
|
80
|
+
delay_seconds: int = 0,
|
|
81
|
+
) -> None:
|
|
82
|
+
payload = {
|
|
83
|
+
"tenant_id": tenant_id,
|
|
84
|
+
"period_start": period_start.astimezone(timezone.utc).isoformat(),
|
|
85
|
+
"period_end": period_end.astimezone(timezone.utc).isoformat(),
|
|
86
|
+
"currency": currency,
|
|
87
|
+
}
|
|
88
|
+
queue.enqueue(BILLING_INVOICE_JOB, payload, delay_seconds=delay_seconds)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def make_daily_aggregate_tick(
|
|
92
|
+
queue: JobQueue,
|
|
93
|
+
*,
|
|
94
|
+
tenant_id: str,
|
|
95
|
+
metric: str,
|
|
96
|
+
when: Optional[datetime] = None,
|
|
97
|
+
):
|
|
98
|
+
"""Return an async function that enqueues a daily aggregate job.
|
|
99
|
+
|
|
100
|
+
This is a simple helper for local/dev schedulers; it schedules an aggregate
|
|
101
|
+
for the UTC day of ``when`` (or now). Call repeatedly via a scheduler.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
async def _tick():
|
|
105
|
+
ts = (when or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
|
106
|
+
day_start = ts.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
107
|
+
enqueue_aggregate_daily(queue, tenant_id=tenant_id, metric=metric, day_start=day_start)
|
|
108
|
+
|
|
109
|
+
return _tick
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def make_billing_job_handler(
|
|
113
|
+
*,
|
|
114
|
+
session_factory: "async_sessionmaker[AsyncSession]",
|
|
115
|
+
webhooks: WebhookService,
|
|
116
|
+
) -> Callable[[Job], Awaitable[None]]:
|
|
117
|
+
"""Create a worker handler that processes billing jobs and emits webhooks.
|
|
118
|
+
|
|
119
|
+
Supported jobs and their expected payloads:
|
|
120
|
+
- billing.aggregate_daily {tenant_id, metric, day_start: ISO8601}
|
|
121
|
+
→ emits topic 'billing.usage_aggregated'
|
|
122
|
+
- billing.generate_monthly_invoice {tenant_id, period_start: ISO8601, period_end: ISO8601, currency}
|
|
123
|
+
→ emits topic 'billing.invoice.created'
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
async def _handler(job: Job) -> None:
|
|
127
|
+
name = job.name
|
|
128
|
+
data: Dict[str, Any] = job.payload or {}
|
|
129
|
+
if name == BILLING_AGGREGATE_JOB:
|
|
130
|
+
tenant_id = str(data.get("tenant_id"))
|
|
131
|
+
metric = str(data.get("metric"))
|
|
132
|
+
day_raw = data.get("day_start")
|
|
133
|
+
if not tenant_id or not metric or not day_raw:
|
|
134
|
+
return
|
|
135
|
+
day_start = datetime.fromisoformat(str(day_raw))
|
|
136
|
+
async with session_factory() as session:
|
|
137
|
+
svc = AsyncBillingService(session=session, tenant_id=tenant_id)
|
|
138
|
+
total = await svc.aggregate_daily(metric=metric, day_start=day_start)
|
|
139
|
+
await session.commit()
|
|
140
|
+
webhooks.publish(
|
|
141
|
+
"billing.usage_aggregated",
|
|
142
|
+
{
|
|
143
|
+
"tenant_id": tenant_id,
|
|
144
|
+
"metric": metric,
|
|
145
|
+
"day_start": day_start.astimezone(timezone.utc).isoformat(),
|
|
146
|
+
"total": int(total),
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
return
|
|
150
|
+
if name == BILLING_INVOICE_JOB:
|
|
151
|
+
tenant_id = str(data.get("tenant_id"))
|
|
152
|
+
period_start_raw = data.get("period_start")
|
|
153
|
+
period_end_raw = data.get("period_end")
|
|
154
|
+
currency = str(data.get("currency"))
|
|
155
|
+
if not tenant_id or not period_start_raw or not period_end_raw or not currency:
|
|
156
|
+
return
|
|
157
|
+
period_start = datetime.fromisoformat(str(period_start_raw))
|
|
158
|
+
period_end = datetime.fromisoformat(str(period_end_raw))
|
|
159
|
+
async with session_factory() as session:
|
|
160
|
+
svc = AsyncBillingService(session=session, tenant_id=tenant_id)
|
|
161
|
+
invoice_id = await svc.generate_monthly_invoice(
|
|
162
|
+
period_start=period_start, period_end=period_end, currency=currency
|
|
163
|
+
)
|
|
164
|
+
await session.commit()
|
|
165
|
+
webhooks.publish(
|
|
166
|
+
"billing.invoice.created",
|
|
167
|
+
{
|
|
168
|
+
"tenant_id": tenant_id,
|
|
169
|
+
"invoice_id": invoice_id,
|
|
170
|
+
"period_start": period_start.astimezone(timezone.utc).isoformat(),
|
|
171
|
+
"period_end": period_end.astimezone(timezone.utc).isoformat(),
|
|
172
|
+
"currency": currency,
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
return
|
|
176
|
+
# Ignore unrelated jobs
|
|
177
|
+
|
|
178
|
+
return _handler
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def add_billing_jobs(
|
|
182
|
+
*,
|
|
183
|
+
scheduler: InMemoryScheduler,
|
|
184
|
+
queue: JobQueue,
|
|
185
|
+
jobs: list[dict],
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Register simple interval-based billing job enqueuers.
|
|
188
|
+
|
|
189
|
+
jobs: list of dicts with shape {"name": "aggregate", "tenant_id": ..., "metric": ..., "interval_seconds": 86400}
|
|
190
|
+
or {"name": "invoice", "tenant_id": ..., "period_start": ISO, "period_end": ISO, "currency": ..., "interval_seconds": 2592000}
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
for j in jobs:
|
|
194
|
+
name = j.get("name")
|
|
195
|
+
interval = int(j.get("interval_seconds", 86400))
|
|
196
|
+
if name == "aggregate":
|
|
197
|
+
tenant_id = j["tenant_id"]
|
|
198
|
+
metric = j["metric"]
|
|
199
|
+
|
|
200
|
+
async def _tick_fn(tid=tenant_id, m=metric):
|
|
201
|
+
# Enqueue for the current UTC day
|
|
202
|
+
now = datetime.now(timezone.utc)
|
|
203
|
+
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
204
|
+
enqueue_aggregate_daily(queue, tenant_id=tid, metric=m, day_start=day_start)
|
|
205
|
+
|
|
206
|
+
scheduler.add_task(f"billing.aggregate.{tenant_id}.{metric}", interval, _tick_fn)
|
|
207
|
+
elif name == "invoice":
|
|
208
|
+
tenant_id = j["tenant_id"]
|
|
209
|
+
currency = j["currency"]
|
|
210
|
+
pstart = datetime.fromisoformat(j["period_start"]).astimezone(timezone.utc)
|
|
211
|
+
pend = datetime.fromisoformat(j["period_end"]).astimezone(timezone.utc)
|
|
212
|
+
|
|
213
|
+
async def _tick_inv(tid=tenant_id, cs=currency, ps=pstart, pe=pend):
|
|
214
|
+
enqueue_generate_monthly_invoice(
|
|
215
|
+
queue, tenant_id=tid, period_start=ps, period_end=pe, currency=cs
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
scheduler.add_task(f"billing.invoice.{tenant_id}", interval, _tick_inv)
|
|
@@ -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
|
|
@@ -14,6 +14,40 @@ We need shared billing primitives to support both usage-based and subscription f
|
|
|
14
14
|
|
|
15
15
|
Non-goals for v1: taxes/VAT, complex proration rules, refunds/credits automation, dunning flows, provider-specific webhooks/end-to-end reconciliation.
|
|
16
16
|
|
|
17
|
+
## Analysis: APF Payments vs Billing Primitives
|
|
18
|
+
|
|
19
|
+
What APF Payments already covers (provider-facing):
|
|
20
|
+
- Subscriptions lifecycle via provider adapters and HTTP router
|
|
21
|
+
- Endpoints: create/update/cancel/get/list under `/payments/subscriptions` (see `api/fastapi/apf_payments/router.py`).
|
|
22
|
+
- Local mirror rows (e.g., `PaySubscription`) are persisted for reference, but state is owned by the provider (Stripe/Aiydan).
|
|
23
|
+
- Plans as Product + Price on the provider side
|
|
24
|
+
- APF Payments exposes products (`/payments/products`) and prices (`/payments/prices`). In Stripe semantics, a “plan” is represented by a product+price pair.
|
|
25
|
+
- There is no first-class internal Plan entity in APF Payments; plan semantics are encapsulated as provider product/price metadata.
|
|
26
|
+
- Invoices, invoice line items, and previews
|
|
27
|
+
- Create/finalize/void/pay invoices; add/list invoice lines; preview invoices — all via provider adapters.
|
|
28
|
+
- Usage records (metered billing) at the provider
|
|
29
|
+
- Create/list/get usage records mapped to provider subscription items or prices (`/payments/usage_records`).
|
|
30
|
+
- Cross-cutting:
|
|
31
|
+
- Tenant resolution, pagination, idempotency, and Problem+JSON errors are integrated.
|
|
32
|
+
|
|
33
|
+
What APF Payments does not cover (gaps filled by Billing Primitives):
|
|
34
|
+
- An internal, provider-agnostic Plan and Entitlement registry (keys, windows, limits).
|
|
35
|
+
- Quota enforcement at runtime (soft/hard limits) against internal entitlements.
|
|
36
|
+
- Internal usage ingestion and aggregation store independent of provider APIs
|
|
37
|
+
- `UsageEvent` and `UsageAggregate` tables, with idempotent ingestion and windowed rollups.
|
|
38
|
+
- Internal invoice modeling and generation from aggregates (not just provider invoices)
|
|
39
|
+
- `Invoice` and `InvoiceLine` entities produced from internal totals (jobs-based lifecycle).
|
|
40
|
+
- A dedicated `/_billing` router for usage ingestion and aggregate reads (tenant-scoped, RBAC-protected).
|
|
41
|
+
|
|
42
|
+
Where they intersect and can complement each other:
|
|
43
|
+
- You can continue to use APF Payments for provider-side subscriptions/invoices and also use Billing Primitives to meter internal features and enforce quotas.
|
|
44
|
+
- Optional bridging: a provider sync hook can map internally generated invoices/lines to provider invoices or payment intents when you want unified billing.
|
|
45
|
+
- Usage: internal `UsageEvent` can be mirrored to provider usage-records if desired, but internal aggregation enables analytics and quota decisions without provider round-trips.
|
|
46
|
+
|
|
47
|
+
Answering “Are plans and subscriptions covered in APF Payments?”
|
|
48
|
+
- Subscriptions: Yes — fully supported via `/payments/subscriptions` endpoints with adapters (Stripe/Aiydan). APF also persists a local `PaySubscription` record for reference.
|
|
49
|
+
- Plans: APF Payments does not expose a standalone internal Plan model. Instead, providers represent plans as Product + Price. Billing Primitives introduces an internal `Plan` and `PlanEntitlement` registry to support provider-agnostic limits and quotas.
|
|
50
|
+
|
|
17
51
|
## Decisions
|
|
18
52
|
|
|
19
53
|
1) Internal-first data model with optional provider adapters
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# ADR 0010: Timeouts & Resource Limits (A2)
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
Services need consistent, configurable timeouts to protect against slowloris/body drip attacks, expensive handlers, slow downstreams, and long-running DB statements. Today we lack unified settings and middleware behavior; some httpx usages hard-code timeouts. We also want consistent Problem+JSON semantics for timeout errors.
|
|
5
|
+
|
|
6
|
+
## Decision
|
|
7
|
+
Introduce environment-driven timeouts and wire them via FastAPI middlewares and helper factories:
|
|
8
|
+
|
|
9
|
+
- Request body read timeout: aborts slow body streaming (e.g., slowloris) with 408 Request Timeout.
|
|
10
|
+
- Overall request timeout: caps handler execution time and returns 504 Gateway Timeout.
|
|
11
|
+
- httpx client defaults: central helpers that pick a sane default timeout from env.
|
|
12
|
+
- DB statement timeout: future work (PG: SET LOCAL statement_timeout; SQLite/dev: asyncio.wait_for wrapper). Scoped in follow-ups.
|
|
13
|
+
- Graceful shutdown: track in-flight HTTP requests and wait up to grace period; provide worker runner with stop/grace.
|
|
14
|
+
|
|
15
|
+
## Configuration
|
|
16
|
+
Environment variables (with suggested defaults):
|
|
17
|
+
|
|
18
|
+
- REQUEST_BODY_TIMEOUT_SECONDS: int, default 15 (prod), 30 (non-prod)
|
|
19
|
+
- REQUEST_TIMEOUT_SECONDS: int, default 30 (prod), 15 (non-prod)
|
|
20
|
+
- HTTP_CLIENT_TIMEOUT_SECONDS: float, default 10.0
|
|
21
|
+
|
|
22
|
+
These are read at process start. Services can override per-env.
|
|
23
|
+
|
|
24
|
+
## Behavior
|
|
25
|
+
- Body read timeout → 408 application/problem+json with title "Request Timeout"; optional Retry-After not included by default.
|
|
26
|
+
- Handler timeout → 504 application/problem+json with title "Gateway Timeout"; include request trace_id in body if present.
|
|
27
|
+
- Errors use existing problem_response helper.
|
|
28
|
+
|
|
29
|
+
## Placement
|
|
30
|
+
- Middlewares under svc_infra.api.fastapi.middleware.timeout
|
|
31
|
+
- Wiring in svc_infra.api.fastapi.setup._setup_middlewares (after RequestId, before error catching).
|
|
32
|
+
- httpx helpers under svc_infra.http.client: new_httpx_client/new_async_httpx_client with env-driven defaults.
|
|
33
|
+
- Graceful shutdown under svc_infra.api.fastapi.middleware.graceful_shutdown and svc_infra.jobs.runner.WorkerRunner.
|
|
34
|
+
|
|
35
|
+
## Alternatives Considered
|
|
36
|
+
- Starlette TimeoutMiddleware: version support/behavior varies; custom middleware gives us consistent Problem+JSON and finer control across environments.
|
|
37
|
+
|
|
38
|
+
## Consequences
|
|
39
|
+
- Adds two middlewares to every app created via setup_service_api/easy_service_app.
|
|
40
|
+
- Minor overhead per request; mitigated by simple asyncio.wait_for usage.
|
|
41
|
+
|
|
42
|
+
## Follow-ups
|
|
43
|
+
- PG statement timeout integration; SQLite/dev wrapper.
|
|
44
|
+
- Jobs/webhook runner per-job timeout.
|
|
45
|
+
- Graceful shutdown drainage hooks for servers/workers.
|
|
46
|
+
- Acceptance tests A2-04..A2-06 per PLANS.
|
|
47
|
+
|
|
48
|
+
## Change log
|
|
49
|
+
- 2025-10-21: Finalized httpx helpers design and placement; proceed to implementation.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
Status: Accepted
|
|
53
|
+
Date: 2025-10-21
|
|
54
|
+
Related: PLANS A2 — Timeouts & Resource Limits
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# Billing Primitives
|
|
2
|
+
|
|
3
|
+
This module provides internal-first billing building blocks for services that need usage-based and subscription billing without coupling to a specific provider. It complements APF Payments (provider-facing) with portable primitives you can use regardless of Stripe/Aiydan/etc.
|
|
4
|
+
|
|
5
|
+
## What you get
|
|
6
|
+
|
|
7
|
+
- Usage ingestion with idempotency (UsageEvent)
|
|
8
|
+
- Windowed usage aggregation (UsageAggregate) — daily baseline
|
|
9
|
+
- Plan and entitlements registry (Plan, PlanEntitlement)
|
|
10
|
+
- Tenant subscriptions (Subscription)
|
|
11
|
+
- Price catalog for fixed/usage items (Price)
|
|
12
|
+
- Invoice and line items (Invoice, InvoiceLine)
|
|
13
|
+
- A small `BillingService` to record usage, aggregate, and generate monthly invoices
|
|
14
|
+
- Optional provider sync hook to mirror internal invoices/lines to your payment provider
|
|
15
|
+
|
|
16
|
+
## Data model (SQL)
|
|
17
|
+
|
|
18
|
+
Tables (v1):
|
|
19
|
+
- usage_events(id, tenant_id, metric, amount, at_ts, idempotency_key, metadata_json, created_at)
|
|
20
|
+
- Unique (tenant_id, metric, idempotency_key)
|
|
21
|
+
- usage_aggregates(id, tenant_id, metric, period_start, granularity, total, updated_at)
|
|
22
|
+
- Unique (tenant_id, metric, period_start, granularity)
|
|
23
|
+
- plans(id, key, name, description, created_at)
|
|
24
|
+
- plan_entitlements(id, plan_id, key, limit_per_window, window, created_at)
|
|
25
|
+
- subscriptions(id, tenant_id, plan_id, effective_at, ended_at, created_at)
|
|
26
|
+
- prices(id, key, currency, unit_amount, metric, recurring_interval, created_at)
|
|
27
|
+
- invoices(id, tenant_id, period_start, period_end, status, total_amount, currency, provider_invoice_id, created_at)
|
|
28
|
+
- invoice_lines(id, invoice_id, price_id, metric, quantity, amount, created_at)
|
|
29
|
+
|
|
30
|
+
See `src/svc_infra/billing/models.py` for full definitions.
|
|
31
|
+
|
|
32
|
+
## Quick start (Python)
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from datetime import datetime, timezone
|
|
36
|
+
from sqlalchemy.orm import Session
|
|
37
|
+
from svc_infra.billing import BillingService
|
|
38
|
+
|
|
39
|
+
# session: SQLAlchemy Session (sync) targeting your DB
|
|
40
|
+
bs = BillingService(session=session, tenant_id="t_123")
|
|
41
|
+
|
|
42
|
+
# 1) Record usage (idempotent by (tenant, metric, idempotency_key))
|
|
43
|
+
evt_id = bs.record_usage(
|
|
44
|
+
metric="tokens", amount=42,
|
|
45
|
+
at=datetime.now(tz=timezone.utc),
|
|
46
|
+
idempotency_key="req-42",
|
|
47
|
+
metadata={"model": "gpt"},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# 2) Aggregate for a day (baseline v1 granularity)
|
|
51
|
+
bs.aggregate_daily(metric="tokens", day_start=datetime(2025,1,1,tzinfo=timezone.utc))
|
|
52
|
+
|
|
53
|
+
# 3) Generate a monthly invoice (fixed+usage lines TBD)
|
|
54
|
+
inv_id = bs.generate_monthly_invoice(
|
|
55
|
+
period_start=datetime(2025,1,1,tzinfo=timezone.utc),
|
|
56
|
+
period_end=datetime(2025,2,1,tzinfo=timezone.utc),
|
|
57
|
+
currency="usd",
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Optional: pass a provider sync hook if you want to mirror invoices/lines to Stripe/Aiydan:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from typing import Callable
|
|
65
|
+
from svc_infra.billing.models import Invoice, InvoiceLine
|
|
66
|
+
|
|
67
|
+
async def sync_to_provider(inv: Invoice, lines: list[InvoiceLine]):
|
|
68
|
+
# Map internal invoice/lines to provider calls here
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
bs = BillingService(session=session, tenant_id="t_123", provider_sync=sync_to_provider)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### FastAPI router (usage ingestion & aggregates)
|
|
75
|
+
|
|
76
|
+
Mount the router and start recording usage with idempotency:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from fastapi import FastAPI
|
|
80
|
+
from svc_infra.api.fastapi.billing.setup import add_billing
|
|
81
|
+
from svc_infra.api.fastapi.middleware.idempotency import IdempotencyMiddleware
|
|
82
|
+
from svc_infra.api.fastapi.middleware.errors.handlers import register_error_handlers
|
|
83
|
+
|
|
84
|
+
app = FastAPI()
|
|
85
|
+
app.add_middleware(IdempotencyMiddleware, store={})
|
|
86
|
+
register_error_handlers(app)
|
|
87
|
+
add_billing(app) # mounts under /_billing
|
|
88
|
+
|
|
89
|
+
# POST /_billing/usage {metric, amount, at?, idempotency_key, metadata?} -> 202 {id}
|
|
90
|
+
# GET /_billing/usage?metric=tokens -> {items: [{period_start, granularity, metric, total}], next_cursor}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Quotas (soft/hard limits)
|
|
94
|
+
|
|
95
|
+
Protect your feature endpoints with a quota dependency based on internal plan entitlements and daily aggregates:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from fastapi import Depends
|
|
99
|
+
from svc_infra.billing.quotas import require_quota
|
|
100
|
+
|
|
101
|
+
@app.get("/generate-report", dependencies=[Depends(require_quota("reports", window="day", soft=False))])
|
|
102
|
+
async def generate_report():
|
|
103
|
+
return {"ok": True}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Relationship to APF Payments
|
|
107
|
+
|
|
108
|
+
- APF Payments is provider-facing: customers, intents, methods, products/prices, subscriptions, invoices, usage records via Stripe/Aiydan adapters and HTTP routers.
|
|
109
|
+
- Billing Primitives is provider-agnostic: an internal ledger of usage, plans/entitlements, and invoices that you can keep even if you change providers.
|
|
110
|
+
- You can use both: continue to use APF Payments for card/payments flows, and use Billing to meter custom features and create internal invoices; selectively sync them out later.
|
|
111
|
+
|
|
112
|
+
## Jobs and webhooks
|
|
113
|
+
|
|
114
|
+
Billing includes helpers to enqueue and process jobs and emit webhooks:
|
|
115
|
+
|
|
116
|
+
- Job names:
|
|
117
|
+
- `billing.aggregate_daily` payload: `{tenant_id, metric, day_start: ISO8601}`
|
|
118
|
+
- `billing.generate_monthly_invoice` payload: `{tenant_id, period_start: ISO8601, period_end: ISO8601, currency}`
|
|
119
|
+
- Emitted webhook topics:
|
|
120
|
+
- `billing.usage_aggregated` payload: `{tenant_id, metric, day_start, total}`
|
|
121
|
+
- `billing.invoice.created` payload: `{tenant_id, invoice_id, period_start, period_end, currency}`
|
|
122
|
+
|
|
123
|
+
Usage with the built-in queue/scheduler and webhooks outbox:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
127
|
+
from svc_infra.jobs.easy import easy_jobs
|
|
128
|
+
from svc_infra.webhooks.add import add_webhooks
|
|
129
|
+
from svc_infra.webhooks.service import WebhookService
|
|
130
|
+
from svc_infra.db.outbox import InMemoryOutboxStore
|
|
131
|
+
from svc_infra.webhooks.service import InMemoryWebhookSubscriptions
|
|
132
|
+
from svc_infra.billing.jobs import (
|
|
133
|
+
enqueue_aggregate_daily,
|
|
134
|
+
enqueue_generate_monthly_invoice,
|
|
135
|
+
make_billing_job_handler,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Create queue + scheduler
|
|
139
|
+
queue, scheduler = easy_jobs()
|
|
140
|
+
|
|
141
|
+
# Setup DB async session factory
|
|
142
|
+
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
143
|
+
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
|
144
|
+
|
|
145
|
+
# Setup webhooks (in-memory stores shown here)
|
|
146
|
+
outbox = InMemoryOutboxStore()
|
|
147
|
+
subs = InMemoryWebhookSubscriptions()
|
|
148
|
+
subs.add("billing.usage_aggregated", url="https://example.test/hook", secret="sekrit")
|
|
149
|
+
webhooks = WebhookService(outbox=outbox, subs=subs)
|
|
150
|
+
|
|
151
|
+
# Worker handler
|
|
152
|
+
handler = make_billing_job_handler(session_factory=SessionLocal, webhooks=webhooks)
|
|
153
|
+
|
|
154
|
+
# Enqueue example jobs
|
|
155
|
+
from datetime import datetime, timezone
|
|
156
|
+
enqueue_aggregate_daily(queue, tenant_id="t1", metric="tokens", day_start=datetime.now(timezone.utc))
|
|
157
|
+
enqueue_generate_monthly_invoice(
|
|
158
|
+
queue, tenant_id="t1", period_start=datetime(2025,1,1,tzinfo=timezone.utc), period_end=datetime(2025,2,1,tzinfo=timezone.utc), currency="usd"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# In your worker loop call process_one(queue, handler)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Roadmap (v1 scope)
|
|
165
|
+
|
|
166
|
+
- Router: `/_billing` endpoints for usage ingestion (idempotent), aggregate listing, plans/subscriptions read.
|
|
167
|
+
- Quotas: decorator/dependency to enforce per-plan limits (soft/hard, day/month windows).
|
|
168
|
+
- Jobs: integrate aggregation and invoice-generation with the scheduler; emit `billing.*` webhooks. (helpers available in `svc_infra.billing.jobs`) — Implemented.
|
|
169
|
+
- Provider sync: optional mapper to Stripe invoices/payment intents; reuse idempotency.
|
|
170
|
+
- Migrations: author initial Alembic migration for billing tables.
|
|
171
|
+
- Docs: examples for quotas and jobs; admin flows for plans and prices.
|
|
172
|
+
|
|
173
|
+
## Testing
|
|
174
|
+
|
|
175
|
+
- See `tests/unit/billing/test_billing_service.py` for usage, aggregation, invoice basics, and idempotency uniqueness.
|
|
176
|
+
- Additions planned: router tests (ingest/list), quotas, job executions, webhook events.
|
|
177
|
+
|
|
178
|
+
## Security & Tenancy
|
|
179
|
+
|
|
180
|
+
- All records are tenant-scoped; ensure tenant_id is enforced in your service layer / router dependencies.
|
|
181
|
+
- Protect HTTP endpoints with RBAC permissions (e.g., billing.read, billing.write) if you expose them.
|
|
182
|
+
|
|
183
|
+
## Observability
|
|
184
|
+
|
|
185
|
+
Planned metrics (names may evolve):
|
|
186
|
+
- billing_usage_ingest_total
|
|
187
|
+
- billing_aggregate_duration_ms
|
|
188
|
+
- billing_invoice_generated_total
|
|
189
|
+
|
|
190
|
+
See ADR 0008 for design details.
|
svc_infra/docs/ops.md
CHANGED
|
@@ -31,3 +31,7 @@ This guide explains how to use svc-infra’s probes, circuit breaker, and metric
|
|
|
31
31
|
|
|
32
32
|
- Prometheus middleware is enabled unless `SVC_INFRA_DISABLE_PROMETHEUS=1`.
|
|
33
33
|
- Observability settings: `METRICS_ENABLED`, `METRICS_PATH`, and optional histogram buckets.
|
|
34
|
+
|
|
35
|
+
## See also
|
|
36
|
+
|
|
37
|
+
- Timeouts & Resource Limits: `./timeouts-and-resource-limits.md` — request/body/handler timeouts, outbound client timeouts, DB statement timeouts, jobs/webhooks, and graceful shutdown.
|
svc_infra/docs/rate-limiting.md
CHANGED
|
@@ -115,6 +115,10 @@ metrics.on_suspect_payload = lambda path, size: logger.warning(
|
|
|
115
115
|
- Consider separate limits for read vs write routes.
|
|
116
116
|
- Combine with request size limits and auth lockout for layered defense.
|
|
117
117
|
|
|
118
|
+
## Related
|
|
119
|
+
|
|
120
|
+
- Timeouts & Resource Limits: `./timeouts-and-resource-limits.md` — complements rate limits by bounding slow uploads, long handlers, and downstream timeouts.
|
|
121
|
+
|
|
118
122
|
## Testing
|
|
119
123
|
|
|
120
124
|
- Use `-m ratelimit` to select rate-limiting tests.
|