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,83 @@
|
|
|
1
|
+
# ADR 0007: Docs & SDKs — Research and Design
|
|
2
|
+
|
|
3
|
+
Status: Proposed
|
|
4
|
+
|
|
5
|
+
Date: 2025-10-16
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
We want a production-ready documentation and SDK experience built on our existing FastAPI scaffolding.
|
|
10
|
+
Current capabilities in the codebase:
|
|
11
|
+
|
|
12
|
+
- Docs endpoints and export
|
|
13
|
+
- `add_docs(app, redoc_url, swagger_url, openapi_url, export_openapi_to)` mounts Swagger, ReDoc, and OpenAPI JSON; optional export on startup.
|
|
14
|
+
- `setup_service_api(...)` renders a landing page with per-version doc cards and local-only root docs.
|
|
15
|
+
- `add_prefixed_docs(...)` exposes scoped docs (e.g., for auth/payments) with per-scope OpenAPI, Swagger, ReDoc.
|
|
16
|
+
- OpenAPI conventions and enrichment pipeline
|
|
17
|
+
- Mutators pipeline (`openapi/mutators.py`) with: conventions, normalized Problem schema, pagination params/components, header params, info mutator, and auth scheme installers.
|
|
18
|
+
- Conventions define `Problem` schema and normalize examples.
|
|
19
|
+
- DX checks
|
|
20
|
+
- OpenAPI Problem+JSON lint in `dx/checks.py` and CLI to validate.
|
|
21
|
+
- SDK stub
|
|
22
|
+
- `add_sdk_generation_stub(app, on_generate=...)` exposes a hook endpoint to trigger SDK generation (no hard deps).
|
|
23
|
+
|
|
24
|
+
Gaps for a complete v1 experience:
|
|
25
|
+
|
|
26
|
+
- Enriched OpenAPI with examples and tags is not yet standardized across routers.
|
|
27
|
+
- No built-in SDK generator CLI; only a stub exists. No pinned toolchain or CI integration.
|
|
28
|
+
- No Postman collection generator.
|
|
29
|
+
- No dark-mode toggle/themes for Swagger/ReDoc (landing page supports light/dark).
|
|
30
|
+
- No smoke tests for generated SDKs.
|
|
31
|
+
|
|
32
|
+
## Decision
|
|
33
|
+
|
|
34
|
+
We will standardize the Docs & SDKs approach around the following:
|
|
35
|
+
|
|
36
|
+
1) OpenAPI enrichment
|
|
37
|
+
- Use existing mutators pipeline and add small mutators to:
|
|
38
|
+
- Inject global tags and tag descriptions for major areas (auth, payments, webhooks, ops).
|
|
39
|
+
- Attach minimal `x-codeSamples` for common operations (curl/httpie).
|
|
40
|
+
- Ensure `Problem` schema and example responses are present across 4xx/5xx.
|
|
41
|
+
- Keep pagination and header parameter mutators enabled by default.
|
|
42
|
+
|
|
43
|
+
2) Docs UI
|
|
44
|
+
- Continue with Swagger UI and ReDoc via `add_docs` and `setup_service_api`.
|
|
45
|
+
- Add an optional dark mode toggle for Swagger UI via custom CSS and a query param (design-only; implement later).
|
|
46
|
+
- Keep local-only exposure of root docs; version-specific docs always exposed under their mount path.
|
|
47
|
+
|
|
48
|
+
3) SDK generation pipeline (tools and layout)
|
|
49
|
+
- TypeScript: `openapi-typescript` to generate types (no runtime client) to `clients/typescript/`.
|
|
50
|
+
- Python: `openapi-python-client` to generate a client package to `clients/python/`.
|
|
51
|
+
- Provide a new CLI group `svc-infra sdk` with subcommands:
|
|
52
|
+
- `svc-infra sdk ts --schema openapi.json --out clients/typescript --package @org/service`
|
|
53
|
+
- `svc-infra sdk py --schema openapi.json --out clients/python --package service_sdk`
|
|
54
|
+
- `svc-infra sdk postman --schema openapi.json --out clients/postman_collection.json` (via converter)
|
|
55
|
+
- Pin generator versions in a minimal tool manifest (poetry extras and npm devDeps suggestions in docs) rather than hard deps in core library.
|
|
56
|
+
- Add optional CI steps to generate SDKs on release tags; artifacts uploaded; publishing pipelines documented.
|
|
57
|
+
|
|
58
|
+
4) Postman collection
|
|
59
|
+
- Use the Postman converter (`openapi-to-postmanv2`) to produce `clients/postman_collection.json` from the exported OpenAPI.
|
|
60
|
+
|
|
61
|
+
5) Testing & verification
|
|
62
|
+
- Extend `dx` checks to include: schema export presence, generator dry-run, and minimal smoke tests:
|
|
63
|
+
- TS: typecheck the generated d.ts.
|
|
64
|
+
- Python: `pip install -e` and import a sample client in a quick script.
|
|
65
|
+
- Keep these checks optional (opt-in via CI config) to avoid burdening minimal users.
|
|
66
|
+
|
|
67
|
+
## Consequences
|
|
68
|
+
|
|
69
|
+
- Pros: Clear, tool-agnostic pipeline; no heavy runtime dependencies; easy local and CI usage; versioned artifacts.
|
|
70
|
+
- Cons: Adds extra tooling expectations (node and python generators) for teams that opt in.
|
|
71
|
+
- Risk: Generator/tooling churn; mitigate by pinning versions and providing stubs/fallbacks.
|
|
72
|
+
|
|
73
|
+
## Implementation Notes (planned)
|
|
74
|
+
|
|
75
|
+
- Provide a small `svc_infra/cli/cmds/sdk` module with Typer commands that shell out to the generators if available, with helpful error messages if missing.
|
|
76
|
+
- Document usage in `docs/docs-and-sdks.md` (to be added), including examples and troubleshooting.
|
|
77
|
+
- Keep all new code behind DX/CLI; core library remains free of generator dependencies.
|
|
78
|
+
|
|
79
|
+
## Out of Scope (v1)
|
|
80
|
+
|
|
81
|
+
- Live “try it” consoles beyond Swagger UI.
|
|
82
|
+
- Multi-language example snippets beyond curl/httpie.
|
|
83
|
+
- Automatic publishing to npm/PyPI (documented manual workflows first).
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# ADR 0008: Billing Primitives (Usage, Quotas, Invoicing)
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
Proposed — Research and Design complete for v1 scope.
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
We need shared billing primitives to support both usage-based and subscription features across services. Goals:
|
|
10
|
+
- Capture fine-grained usage events with idempotency and tenant isolation.
|
|
11
|
+
- Aggregate usage into billable buckets (hour/day/month) with rollups.
|
|
12
|
+
- Enforce entitlements/quotas at runtime (hard/soft limits).
|
|
13
|
+
- Produce invoice data structures and events; enable later integration with external providers (Stripe, Paddle) without coupling core DX to any vendor.
|
|
14
|
+
|
|
15
|
+
Non-goals for v1: taxes/VAT, complex proration rules, refunds/credits automation, dunning flows, provider-specific webhooks/end-to-end reconciliation.
|
|
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
|
+
|
|
51
|
+
## Decisions
|
|
52
|
+
|
|
53
|
+
1) Internal-first data model with optional provider adapters
|
|
54
|
+
- Persist usage, aggregates, plans, subscriptions, invoices in our SQL layer.
|
|
55
|
+
- Provide interfaces for provider adapters (Stripe later) to map internal invoices/lines and sync state when enabled.
|
|
56
|
+
|
|
57
|
+
2) Usage ingestion API + idempotency
|
|
58
|
+
- FastAPI router exposes POST /_billing/usage capturing events: {tenant_id, metric, amount, at, idempotency_key, metadata}.
|
|
59
|
+
- Enforce request idempotency via existing middleware + usage-event unique index on (tenant_id, metric, idempotency_key).
|
|
60
|
+
- Emit webhook event `billing.usage_recorded` (optional).
|
|
61
|
+
|
|
62
|
+
3) Aggregation job (scheduler)
|
|
63
|
+
- Background job reads new UsageEvent rows, aggregates into UsageAggregate by key (tenant, metric, period_start, period_granularity).
|
|
64
|
+
- Granularities: hour, day, month (config). Maintains running totals; idempotent.
|
|
65
|
+
- Emits `billing.usage_aggregated` webhook.
|
|
66
|
+
|
|
67
|
+
4) Entitlements and quotas
|
|
68
|
+
- Define Plan and PlanEntitlement models (feature flags, quotas per window).
|
|
69
|
+
- Subscriptions bind tenant -> plan, effective_at/ended_at.
|
|
70
|
+
- Runtime enforcement via dependency/decorator: `require_quota("metric", window="day", soft=True)` which raises/records when limit exceeded.
|
|
71
|
+
|
|
72
|
+
5) Invoicing primitives
|
|
73
|
+
- Invoice and InvoiceLine models created for each billing cycle (monthly default). Lines derived from aggregates and static prices.
|
|
74
|
+
- Price model: unit amount, currency, metric reference (for metered), or fixed recurring.
|
|
75
|
+
- Emit `billing.invoice_created` and `billing.invoice_finalized` webhooks; provider adapter can consume and sync out.
|
|
76
|
+
|
|
77
|
+
6) Observability
|
|
78
|
+
- Metrics: `billing_usage_ingest_total`, `billing_aggregate_duration_ms`, `billing_invoice_generated_total`.
|
|
79
|
+
- Logs: aggregation windows processed, invoice cycles.
|
|
80
|
+
|
|
81
|
+
7) Security & tenancy
|
|
82
|
+
- All models include tenant_id; APIs require tenant context. RBAC: billing.read/billing.write for admin/operator roles.
|
|
83
|
+
|
|
84
|
+
## Data Model (SQL)
|
|
85
|
+
|
|
86
|
+
Tables (minimal v1):
|
|
87
|
+
- usage_events(id, tenant_id, metric, amount, at_ts, idempotency_key, metadata_json, created_at)
|
|
88
|
+
- Unique (tenant_id, metric, idempotency_key)
|
|
89
|
+
- usage_aggregates(id, tenant_id, metric, period_start, granularity, total, updated_at)
|
|
90
|
+
- Unique (tenant_id, metric, period_start, granularity)
|
|
91
|
+
- plans(id, key, name, description, created_at)
|
|
92
|
+
- plan_entitlements(id, plan_id, key, limit_per_window, window, created_at)
|
|
93
|
+
- subscriptions(id, tenant_id, plan_id, effective_at, ended_at, created_at)
|
|
94
|
+
- prices(id, key, currency, unit_amount, metric, recurring_interval, created_at)
|
|
95
|
+
- invoices(id, tenant_id, period_start, period_end, status, total_amount, currency, created_at)
|
|
96
|
+
- invoice_lines(id, invoice_id, price_id, metric, quantity, amount, created_at)
|
|
97
|
+
|
|
98
|
+
All tables will be scaffolded with our SQL helpers and tenant mixin, with Alembic templates.
|
|
99
|
+
|
|
100
|
+
## APIs
|
|
101
|
+
|
|
102
|
+
- POST /_billing/usage: record usage events (body as above). Returns 202 with event id.
|
|
103
|
+
- GET /_billing/usage: list usage by metric and window (aggregated).
|
|
104
|
+
- GET /_billing/plans, GET /_billing/subscriptions, POST /_billing/subscriptions.
|
|
105
|
+
- GET /_billing/invoices, GET /_billing/invoices/{id}.
|
|
106
|
+
|
|
107
|
+
Routers mounted under a `/_billing` prefix and hidden behind auth + tenant guard. OpenAPI tags: Billing.
|
|
108
|
+
|
|
109
|
+
## Jobs & Webhooks
|
|
110
|
+
|
|
111
|
+
- Job: `aggregate_usage` runs on schedule; creates/updates UsageAggregate rows.
|
|
112
|
+
- Job: `generate_invoices` runs monthly; emits invoice events and inserts Invoice/InvoiceLine rows.
|
|
113
|
+
- Webhooks: `billing.usage_recorded`, `billing.usage_aggregated`, `billing.invoice_created`, `billing.invoice_finalized` (signed via existing module).
|
|
114
|
+
|
|
115
|
+
## Implementation Plan (Phased)
|
|
116
|
+
|
|
117
|
+
Phase 1 (MVP):
|
|
118
|
+
- Models + migrations; CRUD for Plans/Subs/Prices; Usage ingestion + idempotency; Aggregator job (daily granularity); Basic invoice generator (monthly, fixed price + metered by day sum); Webhooks emitted; Tests for ingestion, aggregation, simple invoice.
|
|
119
|
+
|
|
120
|
+
Phase 2:
|
|
121
|
+
- Granularity options (hourly); soft/hard quota decorator; Read APIs; Observability metrics; Docs.
|
|
122
|
+
|
|
123
|
+
Phase 3 (Provider adapter optional):
|
|
124
|
+
- Stripe adapter skeleton: map internal invoices/lines -> Stripe, idempotent sync; basic webhook handler to update statuses.
|
|
125
|
+
|
|
126
|
+
## Alternatives Considered
|
|
127
|
+
|
|
128
|
+
- Provider-first approach (Stripe-only) rejected for v1 to keep core DX portable and support non-card use-cases.
|
|
129
|
+
- Event-stream aggregation (Kafka) out-of-scope for framework baseline—can be integrated later.
|
|
130
|
+
|
|
131
|
+
## Risks
|
|
132
|
+
|
|
133
|
+
- Complexity creep around proration and taxes—explicitly out-of-scope for v1.
|
|
134
|
+
- Performance on large tenants—mitigated by granular aggregation and indexes.
|
|
135
|
+
|
|
136
|
+
## Testing
|
|
137
|
+
|
|
138
|
+
- Unit tests for ingestion idempotency, aggregation correctness, invoice totals.
|
|
139
|
+
- E2E-ish tests using in-memory queue + sqlite.
|
|
140
|
+
|
|
141
|
+
## Documentation
|
|
142
|
+
|
|
143
|
+
- `docs/billing.md`: usage API, quotas, invoice lifecycle, and Stripe adapter notes.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# ADR 0009: Acceptance Harness & Promotion Gate (A0)
|
|
2
|
+
|
|
3
|
+
Date: 2025-10-17
|
|
4
|
+
Status: Proposed
|
|
5
|
+
Decision: Adopt a post-build acceptance harness that brings up an ephemeral stack (Docker Compose) and gates image promotion on acceptance results.
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
- We need a thin but strict pre-deploy acceptance layer that runs after building images, before promotion.
|
|
9
|
+
- It should validate golden paths across domains and basic operational invariants.
|
|
10
|
+
- It must be easy to run locally and in CI and support a backend matrix (in-memory vs Redis+Postgres).
|
|
11
|
+
- Supply-chain checks (SBOM, image scan, provenance) should be part of the gate.
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
- Introduce A0 Acceptance Harness:
|
|
15
|
+
- Compose stack (api + db + redis), Makefile helpers (accept/up/wait/seed/down).
|
|
16
|
+
- Seed CLI/script to create ADMIN/USER/TENANT fixtures and API key.
|
|
17
|
+
- Acceptance tests under `tests/acceptance` with `@pytest.mark.acceptance` and BASE_URL.
|
|
18
|
+
- CI job `build-and-accept` steps: build → compose up → seed → `pytest -m "acceptance or smoke"` → OpenAPI lint + API Doctor → teardown.
|
|
19
|
+
- Supply-chain: generate SBOM, image scan (Trivy/Grype) with severity threshold; upload SBOM.
|
|
20
|
+
- Provenance: sign/attest images via cosign/SLSA (best-effort for v1).
|
|
21
|
+
- Backend matrix: two jobs (in-memory vs Redis+Postgres).
|
|
22
|
+
|
|
23
|
+
## Alternatives
|
|
24
|
+
- Testcontainers-only approach (simpler per-test spin-up) — good DX but slower; we can adopt later for certain suites.
|
|
25
|
+
- Kubernetes-in-Docker (kind) for near-prod parity — heavier; likely a v2 improvement.
|
|
26
|
+
|
|
27
|
+
## Consequences
|
|
28
|
+
- Slightly longer CI time due to matrix and scans.
|
|
29
|
+
- Clearer promotion safety; early detection of config/env gaps.
|
|
30
|
+
|
|
31
|
+
## Implementation Notes
|
|
32
|
+
- Files to add:
|
|
33
|
+
- `docker-compose.test.yml`
|
|
34
|
+
- `Makefile` targets: `accept`, `compose_up`, `wait`, `seed`, `down`
|
|
35
|
+
- `tests/acceptance/` scaffolding: `conftest.py`, `_seed.py`, `_auth.py`, `_http.py`, first tests (headers/CORS)
|
|
36
|
+
- CI: `.github/workflows/ci.yml` job `build-and-accept`
|
|
37
|
+
- Env contracts:
|
|
38
|
+
- `SQL_URL`, `REDIS_URL` for backend matrix; `APP_ENV=test-accept` for toggles.
|
|
39
|
+
- Evidence:
|
|
40
|
+
- CI run URL, SBOM artifact link, scan report, acceptance summary.
|
|
@@ -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,73 @@
|
|
|
1
|
+
# 0011 — Admin scope, permissions, and impersonation
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
- The codebase already provides RBAC/permission helpers: `RequireRoles`, `RequirePermission`, ABAC via `RequireABAC`/`owns_resource`.
|
|
5
|
+
- The central permission registry maps roles → permissions (`svc_infra.security.permissions.PERMISSION_REGISTRY`). Notably, the `admin` role includes: `user.read`, `user.write`, `billing.read`, `billing.write`, and `security.session.{list,revoke}`.
|
|
6
|
+
- Acceptance tests demonstrate an “admin-only” route guarded by `RequirePermission("user.write")` and temporary role override to `admin`.
|
|
7
|
+
- There is no dedicated admin API surface yet, and no impersonation flow; observability docs mention an optional route classifier that can label routes like `public|internal|admin`.
|
|
8
|
+
|
|
9
|
+
## Goals
|
|
10
|
+
- Define a consistent approach for admin-only surfaces and permission alignment.
|
|
11
|
+
- Establish minimal permissions needed for admin operations, including impersonation.
|
|
12
|
+
- Outline an impersonation flow with security and audit guardrails.
|
|
13
|
+
- Prepare for an easy integration helper (`add_admin`) without implementing it yet.
|
|
14
|
+
|
|
15
|
+
## Non-goals
|
|
16
|
+
- Implement admin endpoints or impersonation logic in this ADR.
|
|
17
|
+
- Replace existing permissions/guards — this ADR aligns and extends them.
|
|
18
|
+
|
|
19
|
+
## Decisions
|
|
20
|
+
|
|
21
|
+
1) Permissions alignment and additions
|
|
22
|
+
- Keep permissions as the canonical guard unit; roles remain a mapping to permissions.
|
|
23
|
+
- Extend the registry with a dedicated permission for impersonation:
|
|
24
|
+
- `admin.impersonate`
|
|
25
|
+
- Keep existing entries (`security.session.{list,revoke}` etc.) as-is.
|
|
26
|
+
- Recommended role → permission mapping updates:
|
|
27
|
+
- `admin`: add `admin.impersonate` (retains existing permissions).
|
|
28
|
+
- `auditor`: keep `audit.read` (already present) and may expand in the future.
|
|
29
|
+
|
|
30
|
+
2) Admin router pattern
|
|
31
|
+
- Provide an admin-only router pattern that layers role and permission checks consistently:
|
|
32
|
+
- Top-level: role gate via `RequireRoles("admin")` to reflect the “admin area”.
|
|
33
|
+
- Endpoint-level: permission gates via `RequirePermission(...)` for specific operations.
|
|
34
|
+
- Rationale: roles communicate the coarse-grained area; fine-grained actions are enforced by permissions.
|
|
35
|
+
- A future helper `admin_router()` can wrap `roles_router("admin")` (from `api.fastapi.dual.protected`) for ergonomic mounting.
|
|
36
|
+
|
|
37
|
+
3) Impersonation flow (design)
|
|
38
|
+
- Endpoints:
|
|
39
|
+
- `POST /admin/impersonate/start` — body: `{ user_id, reason }`; requires `admin.impersonate`.
|
|
40
|
+
- `POST /admin/impersonate/stop` — ends the session.
|
|
41
|
+
- Mechanics:
|
|
42
|
+
- When starting, issue a short-lived, signed impersonation token (or set a dedicated cookie) that encodes: original admin principal id, target user id, issued-at, expires-at, and nonce.
|
|
43
|
+
- Downstream identity resolution should reflect the impersonated user for request handling, while preserving the original admin as the "actor" for auditing.
|
|
44
|
+
- Stopping invalidates the token/cookie (server-side revocation list or versioned secret), and subsequent requests fall back to the admin’s own identity.
|
|
45
|
+
- Safety guardrails:
|
|
46
|
+
- Always require `admin.impersonate`.
|
|
47
|
+
- Enforce explicit `reason` and capture request fingerprint (ip hash, user-agent) with the event.
|
|
48
|
+
- Limit scope by tenant/org if applicable; optionally block actions explicitly marked non-impersonable.
|
|
49
|
+
- Set short TTL (e.g., 15 minutes) with sliding refresh disabled.
|
|
50
|
+
|
|
51
|
+
4) Audit logging
|
|
52
|
+
- Emit structured audit events for impersonation lifecycle:
|
|
53
|
+
- `admin.impersonation.started` with actor, target, reason, ip hash, user-agent, and expiry.
|
|
54
|
+
- `admin.impersonation.stopped` with actor, target, and termination reason (expired/manual).
|
|
55
|
+
- Implementation options (future):
|
|
56
|
+
- Minimal: log via the existing logging setup (structured logger, e.g., `logger.bind(...).info("audit", ...)`).
|
|
57
|
+
- Preferred: emit to an audit outbox/table or webhook channel for retention and cross-system visibility.
|
|
58
|
+
|
|
59
|
+
5) Observability and route classification
|
|
60
|
+
- Encourage passing a `route_classifier` that labels admin routes as `admin` (e.g., for `/admin` base path) so metrics/SLO dashboards can split traffic into `public|internal|admin` classes.
|
|
61
|
+
|
|
62
|
+
## Consequences
|
|
63
|
+
- Clear, documented permissions and flow for admin-only features.
|
|
64
|
+
- Minimal surface to add later: `admin_router()` and `add_admin(app, ...)` helper that mounts admin routes and wires impersonation endpoints + audit hooks.
|
|
65
|
+
- Tests to plan when implementing:
|
|
66
|
+
- Role vs permission gating behavior on /admin routes.
|
|
67
|
+
- Impersonation start/stop lifecycle and audit emission.
|
|
68
|
+
- Ownership checks that permit admin bypass where intended (e.g., session revocation).
|
|
69
|
+
|
|
70
|
+
## Follow-ups
|
|
71
|
+
- Update the permission registry to include `admin.impersonate` (and map into `admin`).
|
|
72
|
+
- Implement `admin_router()` and the `add_admin` helper following this ADR.
|
|
73
|
+
- Add admin acceptance tests and documentation for guardrails and operational guidance.
|
svc_infra/docs/api.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# FastAPI helper guide
|
|
2
|
+
|
|
3
|
+
The `svc_infra.api.fastapi` package provides a one-call bootstrap (`easy_service_app`) that wires request IDs, idempotency, rate limiting, and shared docs defaults for every mounted version. 【F:src/svc_infra/api/fastapi/ease.py†L176-L220】【F:src/svc_infra/api/fastapi/setup.py†L55-L129】
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from svc_infra.api.fastapi.ease import easy_service_app
|
|
7
|
+
|
|
8
|
+
app = easy_service_app(
|
|
9
|
+
name="Payments",
|
|
10
|
+
release="1.0.0",
|
|
11
|
+
versions=[("v1", "myapp.api.v1", None)],
|
|
12
|
+
public_cors_origins=["https://app.example.com"],
|
|
13
|
+
)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
### Environment
|
|
17
|
+
|
|
18
|
+
`easy_service_app` merges explicit flags with `EasyAppOptions.from_env()` so you can flip behavior without code changes:
|
|
19
|
+
|
|
20
|
+
- `ENABLE_LOGGING`, `LOG_LEVEL`, `LOG_FORMAT` – control structured logging defaults. 【F:src/svc_infra/api/fastapi/ease.py†L67-L104】
|
|
21
|
+
- `ENABLE_OBS`, `METRICS_PATH`, `OBS_SKIP_PATHS` – opt into Prometheus/OTEL middleware and tweak metrics exposure. 【F:src/svc_infra/api/fastapi/ease.py†L67-L111】
|
|
22
|
+
- `CORS_ALLOW_ORIGINS` – add allow-listed origins when you don’t pass `public_cors_origins`. 【F:src/svc_infra/api/fastapi/setup.py†L47-L88】
|
|
23
|
+
|
|
24
|
+
## Quickstart
|
|
25
|
+
|
|
26
|
+
Use `easy_service_app` for a batteries-included FastAPI with sensible defaults:
|
|
27
|
+
|
|
28
|
+
Inputs
|
|
29
|
+
- name: service display name used in docs and logs
|
|
30
|
+
- release: version string (shown in docs and headers)
|
|
31
|
+
- versions: list of tuples of (prefix, import_path, router_name_or_None)
|
|
32
|
+
- public_cors_origins: list of allowed origins for CORS (default deny if omitted)
|
|
33
|
+
|
|
34
|
+
Defaults
|
|
35
|
+
- Logging: enabled with JSON or plain format based on `LOG_FORMAT`; level from `LOG_LEVEL`
|
|
36
|
+
- Observability: Prometheus metrics and OTEL when `ENABLE_OBS=true`; metrics path from `METRICS_PATH` (default `/metrics`)
|
|
37
|
+
- Security headers: strict defaults; CORS disabled unless allowlist provided or `CORS_ALLOW_ORIGINS` set
|
|
38
|
+
- Health: `/ping`, `/healthz`, `/readyz`, `/startupz` are wired
|
|
39
|
+
|
|
40
|
+
Example
|
|
41
|
+
```python
|
|
42
|
+
from svc_infra.api.fastapi.ease import easy_service_app
|
|
43
|
+
|
|
44
|
+
app = easy_service_app(
|
|
45
|
+
name="Example API",
|
|
46
|
+
release="1.0.0",
|
|
47
|
+
versions=[("v1", "example.api.v1", None)],
|
|
48
|
+
public_cors_origins=["https://app.example.com"],
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Override with environment
|
|
53
|
+
```bash
|
|
54
|
+
export ENABLE_LOGGING=true
|
|
55
|
+
export LOG_LEVEL=INFO
|
|
56
|
+
export ENABLE_OBS=true
|
|
57
|
+
export METRICS_PATH=/metrics
|
|
58
|
+
export CORS_ALLOW_ORIGINS=https://app.example.com,https://admin.example.com
|
|
59
|
+
```
|
svc_infra/docs/auth.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Auth settings
|
|
2
|
+
|
|
3
|
+
`svc_infra.api.fastapi.auth` wraps FastAPI Users with sensible defaults for sessions, OAuth, MFA, and API keys via `add_auth_users`. Configuration comes from `AuthSettings`, which reads environment variables with the `AUTH_` prefix. 【F:src/svc_infra/api/fastapi/auth/add.py†L240-L321】【F:src/svc_infra/api/fastapi/auth/settings.py†L23-L91】
|
|
4
|
+
|
|
5
|
+
### Key environment variables
|
|
6
|
+
|
|
7
|
+
- `AUTH_JWT__SECRET`, `AUTH_JWT__OLD_SECRETS` – rotate signing keys without downtime. 【F:docs/security.md†L63-L70】
|
|
8
|
+
- `AUTH_SMTP_HOST`, `AUTH_SMTP_USERNAME`, `AUTH_SMTP_PASSWORD`, `AUTH_SMTP_FROM` – enable SMTP delivery; required in production. 【F:src/svc_infra/api/fastapi/auth/settings.py†L44-L60】【F:src/svc_infra/api/fastapi/auth/sender.py†L33-L59】
|
|
9
|
+
- `AUTH_SESSION_COOKIE_SECURE`, `AUTH_SESSION_COOKIE_NAME`, `AUTH_SESSION_COOKIE_SAMESITE` – shape session middleware. 【F:src/svc_infra/api/fastapi/auth/settings.py†L65-L88】【F:src/svc_infra/api/fastapi/auth/add.py†L279-L303】
|
|
10
|
+
- `AUTH_PASSWORD_MIN_LENGTH`, `AUTH_PASSWORD_REQUIRE_SYMBOL`, `AUTH_PASSWORD_BREACH_CHECK` – enforce password policy. 【F:docs/security.md†L24-L35】
|
|
11
|
+
- `AUTH_MFA_DEFAULT_ENABLED_FOR_NEW_USERS`, `AUTH_MFA_ENFORCE_FOR_ALL_USERS` – adjust MFA enforcement. 【F:src/svc_infra/api/fastapi/auth/settings.py†L32-L40】
|
|
@@ -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.
|