svc-infra 0.1.589__py3-none-any.whl → 0.1.706__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +871 -0
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +19 -10
- svc_infra/apf_payments/service.py +211 -68
- svc_infra/apf_payments/settings.py +27 -3
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +145 -46
- svc_infra/api/fastapi/apf_payments/setup.py +26 -8
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +27 -14
- svc_infra/api/fastapi/auth/gaurd.py +104 -13
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
- svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +29 -5
- svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
- svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -56
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -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 +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +52 -0
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +53 -0
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +212 -0
- svc_infra/security/audit_service.py +74 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +101 -0
- svc_infra/security/jwt_rotation.py +105 -0
- svc_infra/security/lockout.py +102 -0
- svc_infra/security/models.py +287 -0
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +130 -0
- svc_infra/security/passwords.py +79 -0
- svc_infra/security/permissions.py +171 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +100 -0
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -0
- svc_infra-0.1.589.dist-info/METADATA +0 -79
- svc_infra-0.1.589.dist-info/RECORD +0 -234
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
svc_infra/api/__init__.py
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""svc-infra API module.
|
|
2
|
+
|
|
3
|
+
Re-exports key API utilities from svc_infra.api.fastapi for convenient imports.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
# Re-export from fastapi submodule
|
|
9
|
+
from svc_infra.api.fastapi import (
|
|
10
|
+
# Dual routers
|
|
11
|
+
DualAPIRouter,
|
|
12
|
+
dualize_protected,
|
|
13
|
+
dualize_public,
|
|
14
|
+
dualize_user,
|
|
15
|
+
# Service setup
|
|
16
|
+
ServiceInfo,
|
|
17
|
+
APIVersionSpec,
|
|
18
|
+
setup_service_api,
|
|
19
|
+
easy_service_api,
|
|
20
|
+
easy_service_app,
|
|
21
|
+
setup_caching,
|
|
22
|
+
# Health checks
|
|
23
|
+
add_startup_probe,
|
|
24
|
+
add_health_routes,
|
|
25
|
+
add_dependency_health,
|
|
26
|
+
check_database,
|
|
27
|
+
check_redis,
|
|
28
|
+
check_url,
|
|
29
|
+
# Pagination
|
|
30
|
+
use_pagination,
|
|
31
|
+
text_filter,
|
|
32
|
+
sort_by,
|
|
33
|
+
cursor_window,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
# Dual routers
|
|
38
|
+
"DualAPIRouter",
|
|
39
|
+
"dualize_protected",
|
|
40
|
+
"dualize_public",
|
|
41
|
+
"dualize_user",
|
|
42
|
+
# Service setup
|
|
43
|
+
"ServiceInfo",
|
|
44
|
+
"APIVersionSpec",
|
|
45
|
+
"setup_service_api",
|
|
46
|
+
"easy_service_api",
|
|
47
|
+
"easy_service_app",
|
|
48
|
+
"setup_caching",
|
|
49
|
+
# Health checks
|
|
50
|
+
"add_startup_probe",
|
|
51
|
+
"add_health_routes",
|
|
52
|
+
"add_dependency_health",
|
|
53
|
+
"check_database",
|
|
54
|
+
"check_redis",
|
|
55
|
+
"check_url",
|
|
56
|
+
# Pagination
|
|
57
|
+
"use_pagination",
|
|
58
|
+
"text_filter",
|
|
59
|
+
"sort_by",
|
|
60
|
+
"cursor_window",
|
|
61
|
+
]
|
|
@@ -5,6 +5,14 @@ from svc_infra.api.fastapi.dual import (
|
|
|
5
5
|
dualize_user,
|
|
6
6
|
)
|
|
7
7
|
from svc_infra.api.fastapi.openapi.models import APIVersionSpec, ServiceInfo
|
|
8
|
+
from svc_infra.health import (
|
|
9
|
+
add_dependency_health,
|
|
10
|
+
add_health_routes,
|
|
11
|
+
add_startup_probe,
|
|
12
|
+
check_database,
|
|
13
|
+
check_redis,
|
|
14
|
+
check_url,
|
|
15
|
+
)
|
|
8
16
|
|
|
9
17
|
from .cache.add import setup_caching
|
|
10
18
|
from .ease import easy_service_api, easy_service_app
|
|
@@ -18,6 +26,13 @@ __all__ = [
|
|
|
18
26
|
"dualize_protected",
|
|
19
27
|
"ServiceInfo",
|
|
20
28
|
"APIVersionSpec",
|
|
29
|
+
# Health
|
|
30
|
+
"add_startup_probe",
|
|
31
|
+
"add_health_routes",
|
|
32
|
+
"add_dependency_health",
|
|
33
|
+
"check_database",
|
|
34
|
+
"check_redis",
|
|
35
|
+
"check_url",
|
|
21
36
|
# Ease
|
|
22
37
|
"setup_service_api",
|
|
23
38
|
"easy_service_api",
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hmac
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from hashlib import sha256
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
from typing import Any, Callable, Optional, cast
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
15
|
+
|
|
16
|
+
from ....app.env import get_current_environment, require_secret
|
|
17
|
+
from ....security.permissions import RequirePermission
|
|
18
|
+
from ..auth.security import Identity, Principal, _current_principal
|
|
19
|
+
from ..auth.state import get_auth_state
|
|
20
|
+
from ..db.sql.session import SqlSessionDep
|
|
21
|
+
from ..dual.protected import roles_router
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _b64u(data: bytes) -> str:
|
|
27
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _b64u_decode(s: str) -> bytes:
|
|
31
|
+
pad = "=" * ((4 - len(s) % 4) % 4)
|
|
32
|
+
return base64.urlsafe_b64decode(s + pad)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _sign(payload: dict, *, secret: str) -> str:
|
|
36
|
+
body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
37
|
+
sig = hmac.new(secret.encode("utf-8"), body, sha256).digest()
|
|
38
|
+
return _b64u(body) + "." + _b64u(sig)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _verify(token: str, *, secret: str) -> dict:
|
|
42
|
+
try:
|
|
43
|
+
b64_body, b64_sig = token.split(".", 1)
|
|
44
|
+
body = _b64u_decode(b64_body)
|
|
45
|
+
exp_sig = _b64u_decode(b64_sig)
|
|
46
|
+
got_sig = hmac.new(secret.encode("utf-8"), body, sha256).digest()
|
|
47
|
+
if not hmac.compare_digest(exp_sig, got_sig):
|
|
48
|
+
raise ValueError("bad_signature")
|
|
49
|
+
payload = json.loads(body)
|
|
50
|
+
if int(payload.get("exp", 0)) < int(time.time()):
|
|
51
|
+
raise ValueError("expired")
|
|
52
|
+
return cast(dict[Any, Any], payload)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
raise ValueError("invalid_token") from e
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def admin_router(*, dependencies: Optional[list[Any]] = None, **kwargs) -> APIRouter:
|
|
58
|
+
"""Role-gated admin router for coarse access control.
|
|
59
|
+
|
|
60
|
+
Use permission guards inside endpoints for fine-grained control.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
return cast(APIRouter, roles_router("admin", **kwargs))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def add_admin(
|
|
67
|
+
app,
|
|
68
|
+
*,
|
|
69
|
+
base_path: str = "/admin",
|
|
70
|
+
enable_impersonation: bool = True,
|
|
71
|
+
secret: Optional[str] = None,
|
|
72
|
+
ttl_seconds: int = 15 * 60,
|
|
73
|
+
cookie_name: str = "impersonation",
|
|
74
|
+
impersonation_user_getter: Optional[Callable[[Any, str], Any]] = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Wire admin surfaces with sensible defaults.
|
|
77
|
+
|
|
78
|
+
- Mounts an admin router under base_path.
|
|
79
|
+
- Optionally enables impersonation start/stop endpoints guarded by permissions.
|
|
80
|
+
- Registers a dependency override to honor impersonation cookie globally (idempotent).
|
|
81
|
+
|
|
82
|
+
impersonation_user_getter: optional callable (request, user_id) -> user object.
|
|
83
|
+
If omitted, defaults to loading from SQLAlchemy User model returned by get_auth_state().
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
# Idempotency: only mount once per app instance
|
|
87
|
+
if getattr(app.state, "_admin_added", False):
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
env = get_current_environment()
|
|
91
|
+
_secret = require_secret(
|
|
92
|
+
secret or os.getenv("ADMIN_IMPERSONATION_SECRET") or os.getenv("APP_SECRET"),
|
|
93
|
+
"ADMIN_IMPERSONATION_SECRET or APP_SECRET",
|
|
94
|
+
dev_default="dev-only-admin-impersonation-secret-not-for-production",
|
|
95
|
+
)
|
|
96
|
+
_ttl = int(os.getenv("ADMIN_IMPERSONATION_TTL", str(ttl_seconds)))
|
|
97
|
+
_cookie = os.getenv("ADMIN_IMPERSONATION_COOKIE", cookie_name)
|
|
98
|
+
|
|
99
|
+
r = admin_router(prefix=base_path, tags=["admin"]) # role-gated
|
|
100
|
+
|
|
101
|
+
async def _default_user_getter(
|
|
102
|
+
request: Request, user_id: str, session: SqlSessionDep
|
|
103
|
+
):
|
|
104
|
+
try:
|
|
105
|
+
UserModel, _, _ = get_auth_state()
|
|
106
|
+
except Exception:
|
|
107
|
+
# Fallback: simple shim if auth state not configured
|
|
108
|
+
return SimpleNamespace(id=user_id)
|
|
109
|
+
obj = await cast(Any, session).get(UserModel, user_id)
|
|
110
|
+
if not obj:
|
|
111
|
+
raise HTTPException(404, "user_not_found")
|
|
112
|
+
return obj
|
|
113
|
+
|
|
114
|
+
user_getter = impersonation_user_getter
|
|
115
|
+
|
|
116
|
+
@r.post(
|
|
117
|
+
"/impersonate/start",
|
|
118
|
+
status_code=204,
|
|
119
|
+
dependencies=[RequirePermission("admin.impersonate")],
|
|
120
|
+
)
|
|
121
|
+
async def start_impersonation(
|
|
122
|
+
body: dict,
|
|
123
|
+
request: Request,
|
|
124
|
+
response: Response,
|
|
125
|
+
session: SqlSessionDep,
|
|
126
|
+
identity: Identity,
|
|
127
|
+
):
|
|
128
|
+
target_id = (body or {}).get("user_id")
|
|
129
|
+
reason = (body or {}).get("reason", "")
|
|
130
|
+
if not target_id:
|
|
131
|
+
raise HTTPException(422, "user_id_required")
|
|
132
|
+
# Load target for validation (custom getter or default)
|
|
133
|
+
_res = (
|
|
134
|
+
user_getter(request, target_id)
|
|
135
|
+
if user_getter
|
|
136
|
+
else _default_user_getter(request, target_id, session)
|
|
137
|
+
)
|
|
138
|
+
target = await _res if inspect.isawaitable(_res) else _res
|
|
139
|
+
actor: Principal = identity
|
|
140
|
+
payload = {
|
|
141
|
+
"actor_id": getattr(getattr(actor, "user", None), "id", None),
|
|
142
|
+
"target_id": str(getattr(target, "id", target_id)),
|
|
143
|
+
"iat": int(time.time()),
|
|
144
|
+
"exp": int(time.time()) + _ttl,
|
|
145
|
+
"nonce": _b64u(os.urandom(8)),
|
|
146
|
+
}
|
|
147
|
+
token = _sign(payload, secret=_secret)
|
|
148
|
+
response.set_cookie(
|
|
149
|
+
key=_cookie,
|
|
150
|
+
value=token,
|
|
151
|
+
httponly=True,
|
|
152
|
+
samesite="lax",
|
|
153
|
+
secure=(env in ("prod", "production")),
|
|
154
|
+
path="/",
|
|
155
|
+
max_age=_ttl,
|
|
156
|
+
)
|
|
157
|
+
logger.info(
|
|
158
|
+
"admin.impersonation.started",
|
|
159
|
+
extra={
|
|
160
|
+
"actor_id": payload["actor_id"],
|
|
161
|
+
"target_id": payload["target_id"],
|
|
162
|
+
"reason": reason,
|
|
163
|
+
"expires_in": _ttl,
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
# Re-compose override now to wrap any late overrides set by tests/harness
|
|
167
|
+
try:
|
|
168
|
+
_compose_override()
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
@r.post("/impersonate/stop", status_code=204)
|
|
173
|
+
async def stop_impersonation(response: Response):
|
|
174
|
+
response.delete_cookie(_cookie, path="/")
|
|
175
|
+
logger.info("admin.impersonation.stopped")
|
|
176
|
+
|
|
177
|
+
app.include_router(r)
|
|
178
|
+
|
|
179
|
+
# Dependency override: wrap the base principal to honor impersonation cookie.
|
|
180
|
+
# Compose with any existing override (e.g., acceptance app/test harness) and
|
|
181
|
+
# re-compose at startup to capture late overrides.
|
|
182
|
+
def _compose_override():
|
|
183
|
+
existing = app.dependency_overrides.get(_current_principal)
|
|
184
|
+
if existing and getattr(existing, "_is_admin_impersonation_override", False):
|
|
185
|
+
dep_provider = getattr(
|
|
186
|
+
existing, "_admin_impersonation_base", _current_principal
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
dep_provider = existing or _current_principal
|
|
190
|
+
|
|
191
|
+
async def _override_current_principal(
|
|
192
|
+
request: Request,
|
|
193
|
+
session: SqlSessionDep,
|
|
194
|
+
base: Principal = Depends(dep_provider),
|
|
195
|
+
) -> Principal:
|
|
196
|
+
token = request.cookies.get(_cookie) if request else None
|
|
197
|
+
if not token:
|
|
198
|
+
return base
|
|
199
|
+
try:
|
|
200
|
+
payload = _verify(token, secret=_secret)
|
|
201
|
+
except Exception:
|
|
202
|
+
return base
|
|
203
|
+
# Load target user
|
|
204
|
+
target_id = payload.get("target_id")
|
|
205
|
+
if not target_id:
|
|
206
|
+
return base
|
|
207
|
+
# Preserve actor roles/claims so permissions remain that of the actor
|
|
208
|
+
actor_user = getattr(base, "user", None)
|
|
209
|
+
actor_roles = getattr(actor_user, "roles", []) or []
|
|
210
|
+
_res = (
|
|
211
|
+
user_getter(request, target_id)
|
|
212
|
+
if user_getter
|
|
213
|
+
else _default_user_getter(request, target_id, session)
|
|
214
|
+
)
|
|
215
|
+
target = await _res if inspect.isawaitable(_res) else _res
|
|
216
|
+
# Swap user but keep actor for audit if needed
|
|
217
|
+
setattr(base, "actor", getattr(base, "user", None))
|
|
218
|
+
# If target lacks roles, inherit actor roles to maintain permission checks
|
|
219
|
+
try:
|
|
220
|
+
if not getattr(target, "roles", None):
|
|
221
|
+
setattr(target, "roles", actor_roles)
|
|
222
|
+
except Exception:
|
|
223
|
+
# Best-effort; if target object is immutable, fallback by wrapping
|
|
224
|
+
target = SimpleNamespace(
|
|
225
|
+
id=getattr(target, "id", target_id), roles=actor_roles
|
|
226
|
+
)
|
|
227
|
+
base.user = target
|
|
228
|
+
base.via = "impersonated"
|
|
229
|
+
return base
|
|
230
|
+
|
|
231
|
+
app.dependency_overrides[_current_principal] = _override_current_principal
|
|
232
|
+
_override_current_principal._is_admin_impersonation_override = True # type: ignore[attr-defined]
|
|
233
|
+
_override_current_principal._admin_impersonation_base = dep_provider # type: ignore[attr-defined]
|
|
234
|
+
|
|
235
|
+
# Compose now (best-effort) and again on startup to wrap any later overrides
|
|
236
|
+
_compose_override()
|
|
237
|
+
try:
|
|
238
|
+
app.add_event_handler("startup", _compose_override)
|
|
239
|
+
except Exception:
|
|
240
|
+
# Best-effort; if app doesn't support event handlers, we already composed once
|
|
241
|
+
pass
|
|
242
|
+
app.state._admin_added = True
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# no extra helpers
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Callable, Literal, Optional, cast
|
|
4
5
|
|
|
5
|
-
from fastapi import Body, Depends, Header, Request, Response, status
|
|
6
|
+
from fastapi import Body, Depends, Header, HTTPException, Request, Response, status
|
|
6
7
|
from starlette.responses import JSONResponse
|
|
7
8
|
|
|
8
9
|
from svc_infra.apf_payments.schemas import (
|
|
@@ -47,8 +48,14 @@ from svc_infra.apf_payments.schemas import (
|
|
|
47
48
|
WebhookReplayOut,
|
|
48
49
|
)
|
|
49
50
|
from svc_infra.apf_payments.service import PaymentsService
|
|
51
|
+
from svc_infra.api.fastapi.auth.security import OptionalIdentity, Principal
|
|
50
52
|
from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
|
|
51
|
-
from svc_infra.api.fastapi.dual import
|
|
53
|
+
from svc_infra.api.fastapi.dual import (
|
|
54
|
+
protected_router,
|
|
55
|
+
public_router,
|
|
56
|
+
service_router,
|
|
57
|
+
user_router,
|
|
58
|
+
)
|
|
52
59
|
from svc_infra.api.fastapi.dual.router import DualAPIRouter
|
|
53
60
|
from svc_infra.api.fastapi.middleware.idempotency import require_idempotency_key
|
|
54
61
|
from svc_infra.api.fastapi.pagination import (
|
|
@@ -68,9 +75,84 @@ def _tx_kind(kind: str) -> Literal["payment", "refund", "fee", "payout", "captur
|
|
|
68
75
|
return cast(Literal["payment", "refund", "fee", "payout", "capture"], kind)
|
|
69
76
|
|
|
70
77
|
|
|
78
|
+
# --- tenant resolution ---
|
|
79
|
+
_tenant_resolver: Callable | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def set_payments_tenant_resolver(fn):
|
|
83
|
+
"""Set or clear an override hook for payments tenant resolution.
|
|
84
|
+
|
|
85
|
+
fn(request: Request, identity: Principal | None, header: str | None) -> str | None
|
|
86
|
+
Return a tenant_id to override, or None to defer to default flow.
|
|
87
|
+
"""
|
|
88
|
+
global _tenant_resolver
|
|
89
|
+
_tenant_resolver = fn
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def resolve_payments_tenant_id(
|
|
93
|
+
request: Request,
|
|
94
|
+
identity: Principal | None = None,
|
|
95
|
+
tenant_header: str | None = None,
|
|
96
|
+
) -> str:
|
|
97
|
+
# 1) Override hook
|
|
98
|
+
if _tenant_resolver is not None:
|
|
99
|
+
val = _tenant_resolver(request, identity, tenant_header)
|
|
100
|
+
# Support async or sync resolver
|
|
101
|
+
if inspect.isawaitable(val):
|
|
102
|
+
val = await val
|
|
103
|
+
if val:
|
|
104
|
+
return cast(str, val)
|
|
105
|
+
# if None, continue default flow
|
|
106
|
+
|
|
107
|
+
# 2) Principal (user)
|
|
108
|
+
if identity and getattr(identity.user or object(), "tenant_id", None):
|
|
109
|
+
return cast(str, getattr(identity.user, "tenant_id"))
|
|
110
|
+
|
|
111
|
+
# 3) Principal (api key)
|
|
112
|
+
if identity and getattr(identity.api_key or object(), "tenant_id", None):
|
|
113
|
+
return cast(str, getattr(identity.api_key, "tenant_id"))
|
|
114
|
+
|
|
115
|
+
# 4) Explicit header argument (tests pass this)
|
|
116
|
+
if tenant_header:
|
|
117
|
+
return tenant_header
|
|
118
|
+
|
|
119
|
+
# 5) Request state
|
|
120
|
+
state_tid = getattr(getattr(request, "state", object()), "tenant_id", None)
|
|
121
|
+
if state_tid:
|
|
122
|
+
return cast(str, state_tid)
|
|
123
|
+
|
|
124
|
+
raise HTTPException(status_code=400, detail="tenant_context_missing")
|
|
125
|
+
|
|
126
|
+
|
|
71
127
|
# --- deps ---
|
|
72
|
-
async def get_service(
|
|
73
|
-
|
|
128
|
+
async def get_service(
|
|
129
|
+
session: SqlSessionDep,
|
|
130
|
+
request: Request = ..., # type: ignore[assignment] # FastAPI will inject; tests may omit
|
|
131
|
+
identity: OptionalIdentity = None,
|
|
132
|
+
tenant_id: str | None = None,
|
|
133
|
+
) -> PaymentsService:
|
|
134
|
+
# Derive tenant id if not supplied explicitly
|
|
135
|
+
tid = tenant_id
|
|
136
|
+
if tid is None:
|
|
137
|
+
try:
|
|
138
|
+
if request is not ...:
|
|
139
|
+
tid = await resolve_payments_tenant_id(request, identity=identity)
|
|
140
|
+
else:
|
|
141
|
+
# allow tests to call without a Request; try identity or fallback
|
|
142
|
+
if identity and getattr(identity.user or object(), "tenant_id", None):
|
|
143
|
+
tid = getattr(identity.user, "tenant_id")
|
|
144
|
+
elif identity and getattr(
|
|
145
|
+
identity.api_key or object(), "tenant_id", None
|
|
146
|
+
):
|
|
147
|
+
tid = getattr(identity.api_key, "tenant_id")
|
|
148
|
+
else:
|
|
149
|
+
raise HTTPException(
|
|
150
|
+
status_code=400, detail="tenant_context_missing"
|
|
151
|
+
)
|
|
152
|
+
except HTTPException:
|
|
153
|
+
# fallback for routes/tests that don't set context; preserve prior default
|
|
154
|
+
tid = "test_tenant"
|
|
155
|
+
return PaymentsService(session=session, tenant_id=tid)
|
|
74
156
|
|
|
75
157
|
|
|
76
158
|
# --- routers grouped by auth posture (same prefix is fine; FastAPI merges) ---
|
|
@@ -90,7 +172,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
|
|
|
90
172
|
dependencies=[Depends(require_idempotency_key)],
|
|
91
173
|
tags=["Customers"],
|
|
92
174
|
)
|
|
93
|
-
async def upsert_customer(
|
|
175
|
+
async def upsert_customer(
|
|
176
|
+
data: CustomerUpsertIn, svc: PaymentsService = Depends(get_service)
|
|
177
|
+
):
|
|
94
178
|
out = await svc.ensure_customer(data)
|
|
95
179
|
await svc.session.flush()
|
|
96
180
|
return out
|
|
@@ -113,7 +197,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
|
|
|
113
197
|
out = await svc.create_intent(user_id=None, data=data)
|
|
114
198
|
await svc.session.flush()
|
|
115
199
|
response.headers["Location"] = str(
|
|
116
|
-
request.url_for(
|
|
200
|
+
request.url_for(
|
|
201
|
+
"payments_get_intent", provider_intent_id=out.provider_intent_id
|
|
202
|
+
)
|
|
117
203
|
)
|
|
118
204
|
return out
|
|
119
205
|
|
|
@@ -127,7 +213,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
|
|
|
127
213
|
dependencies=[Depends(require_idempotency_key)],
|
|
128
214
|
tags=["Payment Intents"],
|
|
129
215
|
)
|
|
130
|
-
async def confirm_intent(
|
|
216
|
+
async def confirm_intent(
|
|
217
|
+
provider_intent_id: str, svc: PaymentsService = Depends(get_service)
|
|
218
|
+
):
|
|
131
219
|
out = await svc.confirm_intent(provider_intent_id)
|
|
132
220
|
await svc.session.flush()
|
|
133
221
|
return out
|
|
@@ -139,7 +227,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
|
|
|
139
227
|
dependencies=[Depends(require_idempotency_key)],
|
|
140
228
|
tags=["Payment Intents"],
|
|
141
229
|
)
|
|
142
|
-
async def cancel_intent(
|
|
230
|
+
async def cancel_intent(
|
|
231
|
+
provider_intent_id: str, svc: PaymentsService = Depends(get_service)
|
|
232
|
+
):
|
|
143
233
|
out = await svc.cancel_intent(provider_intent_id)
|
|
144
234
|
await svc.session.flush()
|
|
145
235
|
return out
|
|
@@ -152,7 +242,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
|
|
|
152
242
|
tags=["Payment Intents", "Refunds"],
|
|
153
243
|
)
|
|
154
244
|
async def refund_intent(
|
|
155
|
-
provider_intent_id: str,
|
|
245
|
+
provider_intent_id: str,
|
|
246
|
+
data: RefundIn,
|
|
247
|
+
svc: PaymentsService = Depends(get_service),
|
|
156
248
|
):
|
|
157
249
|
out = await svc.refund(provider_intent_id, data)
|
|
158
250
|
await svc.session.flush()
|
|
@@ -267,7 +359,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
|
|
|
267
359
|
dependencies=[Depends(require_idempotency_key)],
|
|
268
360
|
tags=["Payment Methods"],
|
|
269
361
|
)
|
|
270
|
-
async def detach_method(
|
|
362
|
+
async def detach_method(
|
|
363
|
+
provider_method_id: str, svc: PaymentsService = Depends(get_service)
|
|
364
|
+
):
|
|
271
365
|
out = await svc.detach_payment_method(provider_method_id)
|
|
272
366
|
await svc.session.flush()
|
|
273
367
|
return out
|
|
@@ -284,7 +378,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
|
|
|
284
378
|
customer_provider_id: str,
|
|
285
379
|
svc: PaymentsService = Depends(get_service),
|
|
286
380
|
):
|
|
287
|
-
out = await svc.set_default_payment_method(
|
|
381
|
+
out = await svc.set_default_payment_method(
|
|
382
|
+
customer_provider_id, provider_method_id
|
|
383
|
+
)
|
|
288
384
|
await svc.session.flush()
|
|
289
385
|
return out
|
|
290
386
|
|
|
@@ -297,7 +393,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
|
|
|
297
393
|
dependencies=[Depends(require_idempotency_key)],
|
|
298
394
|
tags=["Products"],
|
|
299
395
|
)
|
|
300
|
-
async def create_product(
|
|
396
|
+
async def create_product(
|
|
397
|
+
data: ProductCreateIn, svc: PaymentsService = Depends(get_service)
|
|
398
|
+
):
|
|
301
399
|
out = await svc.create_product(data)
|
|
302
400
|
await svc.session.flush()
|
|
303
401
|
return out
|
|
@@ -310,7 +408,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
|
|
|
310
408
|
dependencies=[Depends(require_idempotency_key)],
|
|
311
409
|
tags=["Prices"],
|
|
312
410
|
)
|
|
313
|
-
async def create_price(
|
|
411
|
+
async def create_price(
|
|
412
|
+
data: PriceCreateIn, svc: PaymentsService = Depends(get_service)
|
|
413
|
+
):
|
|
314
414
|
out = await svc.create_price(data)
|
|
315
415
|
await svc.session.flush()
|
|
316
416
|
return out
|
|
@@ -381,7 +481,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
|
|
|
381
481
|
out = await svc.create_invoice(data)
|
|
382
482
|
await svc.session.flush()
|
|
383
483
|
response.headers["Location"] = str(
|
|
384
|
-
request.url_for(
|
|
484
|
+
request.url_for(
|
|
485
|
+
"payments_get_invoice", provider_invoice_id=out.provider_invoice_id
|
|
486
|
+
)
|
|
385
487
|
)
|
|
386
488
|
return out
|
|
387
489
|
|
|
@@ -406,7 +508,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
|
|
|
406
508
|
dependencies=[Depends(require_idempotency_key)],
|
|
407
509
|
tags=["Invoices"],
|
|
408
510
|
)
|
|
409
|
-
async def void_invoice(
|
|
511
|
+
async def void_invoice(
|
|
512
|
+
provider_invoice_id: str, svc: PaymentsService = Depends(get_service)
|
|
513
|
+
):
|
|
410
514
|
out = await svc.void_invoice(provider_invoice_id)
|
|
411
515
|
await svc.session.flush()
|
|
412
516
|
return out
|
|
@@ -418,7 +522,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
|
|
|
418
522
|
dependencies=[Depends(require_idempotency_key)],
|
|
419
523
|
tags=["Invoices"],
|
|
420
524
|
)
|
|
421
|
-
async def pay_invoice(
|
|
525
|
+
async def pay_invoice(
|
|
526
|
+
provider_invoice_id: str, svc: PaymentsService = Depends(get_service)
|
|
527
|
+
):
|
|
422
528
|
out = await svc.pay_invoice(provider_invoice_id)
|
|
423
529
|
await svc.session.flush()
|
|
424
530
|
return out
|
|
@@ -430,7 +536,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
|
|
|
430
536
|
name="payments_get_intent",
|
|
431
537
|
tags=["Payment Intents"],
|
|
432
538
|
)
|
|
433
|
-
async def get_intent(
|
|
539
|
+
async def get_intent(
|
|
540
|
+
provider_intent_id: str, svc: PaymentsService = Depends(get_service)
|
|
541
|
+
):
|
|
434
542
|
return await svc.get_intent(provider_intent_id)
|
|
435
543
|
|
|
436
544
|
# STATEMENTS (rollup)
|
|
@@ -651,7 +759,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
|
|
|
651
759
|
response_model=DisputeOut,
|
|
652
760
|
tags=["Disputes"],
|
|
653
761
|
)
|
|
654
|
-
async def get_dispute(
|
|
762
|
+
async def get_dispute(
|
|
763
|
+
provider_dispute_id: str, svc: PaymentsService = Depends(get_service)
|
|
764
|
+
):
|
|
655
765
|
return await svc.get_dispute(provider_dispute_id)
|
|
656
766
|
|
|
657
767
|
@prot.post(
|
|
@@ -663,7 +773,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
|
|
|
663
773
|
)
|
|
664
774
|
async def submit_dispute_evidence(
|
|
665
775
|
provider_dispute_id: str,
|
|
666
|
-
evidence: dict = Body(
|
|
776
|
+
evidence: dict = Body(
|
|
777
|
+
..., embed=True
|
|
778
|
+
), # free-form evidence blob you validate internally
|
|
667
779
|
svc: PaymentsService = Depends(get_service),
|
|
668
780
|
):
|
|
669
781
|
out = await svc.submit_dispute_evidence(provider_dispute_id, evidence)
|
|
@@ -672,7 +784,10 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
|
|
|
672
784
|
|
|
673
785
|
# ===== Balance & Payouts =====
|
|
674
786
|
@prot.get(
|
|
675
|
-
"/balance",
|
|
787
|
+
"/balance",
|
|
788
|
+
name="payments_get_balance",
|
|
789
|
+
response_model=BalanceSnapshotOut,
|
|
790
|
+
tags=["Balance"],
|
|
676
791
|
)
|
|
677
792
|
async def get_balance(svc: PaymentsService = Depends(get_service)):
|
|
678
793
|
return await svc.get_balance_snapshot()
|
|
@@ -695,7 +810,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
|
|
|
695
810
|
response_model=PayoutOut,
|
|
696
811
|
tags=["Payouts"],
|
|
697
812
|
)
|
|
698
|
-
async def get_payout(
|
|
813
|
+
async def get_payout(
|
|
814
|
+
provider_payout_id: str, svc: PaymentsService = Depends(get_service)
|
|
815
|
+
):
|
|
699
816
|
return await svc.get_payout(provider_payout_id)
|
|
700
817
|
|
|
701
818
|
# ===== Webhook replay (operational) =====
|
|
@@ -755,7 +872,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
|
|
|
755
872
|
name="payments_get_method",
|
|
756
873
|
tags=["Payment Methods"],
|
|
757
874
|
)
|
|
758
|
-
async def get_method(
|
|
875
|
+
async def get_method(
|
|
876
|
+
provider_method_id: str, svc: PaymentsService = Depends(get_service)
|
|
877
|
+
):
|
|
759
878
|
return await svc.get_payment_method(provider_method_id)
|
|
760
879
|
|
|
761
880
|
@prot.post(
|
|
@@ -985,28 +1104,6 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
|
|
|
985
1104
|
):
|
|
986
1105
|
return await svc.get_usage_record(usage_record_id)
|
|
987
1106
|
|
|
988
|
-
# --- Invoices: delete line item ---
|
|
989
|
-
@prot.delete(
|
|
990
|
-
"/invoices/{provider_invoice_id}/lines/{provider_line_item_id}",
|
|
991
|
-
name="payments_delete_invoice_line_item",
|
|
992
|
-
summary="Delete Invoice Line Item (draft invoices only)",
|
|
993
|
-
response_model=InvoiceOut,
|
|
994
|
-
dependencies=[Depends(require_idempotency_key)],
|
|
995
|
-
tags=["Invoices"],
|
|
996
|
-
)
|
|
997
|
-
async def delete_invoice_line_item_endpoint(
|
|
998
|
-
provider_invoice_id: str,
|
|
999
|
-
provider_line_item_id: str,
|
|
1000
|
-
svc: PaymentsService = Depends(get_service),
|
|
1001
|
-
):
|
|
1002
|
-
"""
|
|
1003
|
-
Removes a line item from a DRAFT invoice only. For finalized invoices,
|
|
1004
|
-
use `void` or `credit` flows instead.
|
|
1005
|
-
"""
|
|
1006
|
-
out = await svc.delete_invoice_line_item(provider_invoice_id, provider_line_item_id)
|
|
1007
|
-
await svc.session.flush()
|
|
1008
|
-
return out
|
|
1009
|
-
|
|
1010
1107
|
# --- Canonical: remove local alias/association (non-destructive) ---
|
|
1011
1108
|
@prot.delete(
|
|
1012
1109
|
"/method_aliases/{alias_id}",
|
|
@@ -1016,7 +1113,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
|
|
|
1016
1113
|
dependencies=[Depends(require_idempotency_key)],
|
|
1017
1114
|
tags=["Payment Methods"],
|
|
1018
1115
|
)
|
|
1019
|
-
async def delete_method_alias(
|
|
1116
|
+
async def delete_method_alias(
|
|
1117
|
+
alias_id: str, svc: PaymentsService = Depends(get_service)
|
|
1118
|
+
):
|
|
1020
1119
|
"""
|
|
1021
1120
|
Removes the local alias/association to a payment method.
|
|
1022
1121
|
This does **not** delete the underlying payment method at the provider.
|