svc-infra 0.1.562__py3-none-any.whl → 0.1.654__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/models.py +142 -4
  3. svc_infra/apf_payments/provider/__init__.py +4 -0
  4. svc_infra/apf_payments/provider/aiydan.py +797 -0
  5. svc_infra/apf_payments/provider/base.py +178 -12
  6. svc_infra/apf_payments/provider/stripe.py +757 -48
  7. svc_infra/apf_payments/schemas.py +163 -1
  8. svc_infra/apf_payments/service.py +582 -42
  9. svc_infra/apf_payments/settings.py +22 -2
  10. svc_infra/api/fastapi/admin/__init__.py +3 -0
  11. svc_infra/api/fastapi/admin/add.py +231 -0
  12. svc_infra/api/fastapi/apf_payments/router.py +792 -73
  13. svc_infra/api/fastapi/apf_payments/setup.py +13 -4
  14. svc_infra/api/fastapi/auth/add.py +10 -4
  15. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  16. svc_infra/api/fastapi/auth/routers/oauth_router.py +74 -34
  17. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  18. svc_infra/api/fastapi/auth/settings.py +2 -0
  19. svc_infra/api/fastapi/billing/router.py +64 -0
  20. svc_infra/api/fastapi/billing/setup.py +19 -0
  21. svc_infra/api/fastapi/cache/add.py +9 -5
  22. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  23. svc_infra/api/fastapi/db/sql/add.py +40 -18
  24. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  25. svc_infra/api/fastapi/db/sql/session.py +16 -0
  26. svc_infra/api/fastapi/db/sql/users.py +13 -1
  27. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  28. svc_infra/api/fastapi/docs/add.py +160 -0
  29. svc_infra/api/fastapi/docs/landing.py +1 -1
  30. svc_infra/api/fastapi/docs/scoped.py +41 -6
  31. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  32. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  33. svc_infra/api/fastapi/middleware/idempotency.py +82 -42
  34. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  35. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  36. svc_infra/api/fastapi/middleware/ratelimit.py +84 -11
  37. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  38. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  39. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  40. svc_infra/api/fastapi/openapi/mutators.py +244 -38
  41. svc_infra/api/fastapi/ops/add.py +73 -0
  42. svc_infra/api/fastapi/pagination.py +133 -32
  43. svc_infra/api/fastapi/routers/ping.py +1 -0
  44. svc_infra/api/fastapi/setup.py +23 -14
  45. svc_infra/api/fastapi/tenancy/add.py +19 -0
  46. svc_infra/api/fastapi/tenancy/context.py +112 -0
  47. svc_infra/api/fastapi/versioned.py +101 -0
  48. svc_infra/app/README.md +5 -5
  49. svc_infra/billing/__init__.py +23 -0
  50. svc_infra/billing/async_service.py +147 -0
  51. svc_infra/billing/jobs.py +230 -0
  52. svc_infra/billing/models.py +131 -0
  53. svc_infra/billing/quotas.py +101 -0
  54. svc_infra/billing/schemas.py +33 -0
  55. svc_infra/billing/service.py +115 -0
  56. svc_infra/bundled_docs/README.md +5 -0
  57. svc_infra/bundled_docs/__init__.py +1 -0
  58. svc_infra/bundled_docs/getting-started.md +6 -0
  59. svc_infra/cache/__init__.py +4 -0
  60. svc_infra/cache/add.py +158 -0
  61. svc_infra/cache/backend.py +5 -2
  62. svc_infra/cache/decorators.py +19 -1
  63. svc_infra/cache/keys.py +24 -4
  64. svc_infra/cli/__init__.py +32 -8
  65. svc_infra/cli/__main__.py +4 -0
  66. svc_infra/cli/cmds/__init__.py +10 -0
  67. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  68. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  69. svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
  70. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  71. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  72. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  73. svc_infra/cli/cmds/dx/__init__.py +12 -0
  74. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  75. svc_infra/cli/cmds/help.py +4 -0
  76. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  77. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  78. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  79. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  80. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  81. svc_infra/data/add.py +61 -0
  82. svc_infra/data/backup.py +53 -0
  83. svc_infra/data/erasure.py +45 -0
  84. svc_infra/data/fixtures.py +40 -0
  85. svc_infra/data/retention.py +55 -0
  86. svc_infra/db/inbox.py +67 -0
  87. svc_infra/db/nosql/mongo/README.md +13 -13
  88. svc_infra/db/outbox.py +104 -0
  89. svc_infra/db/sql/repository.py +52 -12
  90. svc_infra/db/sql/resource.py +5 -0
  91. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  92. svc_infra/db/sql/templates/setup/env_async.py.tmpl +13 -8
  93. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +9 -5
  94. svc_infra/db/sql/tenant.py +79 -0
  95. svc_infra/db/sql/utils.py +18 -4
  96. svc_infra/db/sql/versioning.py +14 -0
  97. svc_infra/docs/acceptance-matrix.md +71 -0
  98. svc_infra/docs/acceptance.md +44 -0
  99. svc_infra/docs/admin.md +425 -0
  100. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  101. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  102. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  103. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  104. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  105. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  106. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  107. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  108. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  109. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  110. svc_infra/docs/api.md +59 -0
  111. svc_infra/docs/auth.md +11 -0
  112. svc_infra/docs/billing.md +190 -0
  113. svc_infra/docs/cache.md +76 -0
  114. svc_infra/docs/cli.md +74 -0
  115. svc_infra/docs/contributing.md +34 -0
  116. svc_infra/docs/data-lifecycle.md +52 -0
  117. svc_infra/docs/database.md +14 -0
  118. svc_infra/docs/docs-and-sdks.md +62 -0
  119. svc_infra/docs/environment.md +114 -0
  120. svc_infra/docs/getting-started.md +63 -0
  121. svc_infra/docs/idempotency.md +111 -0
  122. svc_infra/docs/jobs.md +67 -0
  123. svc_infra/docs/observability.md +16 -0
  124. svc_infra/docs/ops.md +37 -0
  125. svc_infra/docs/rate-limiting.md +125 -0
  126. svc_infra/docs/repo-review.md +48 -0
  127. svc_infra/docs/security.md +176 -0
  128. svc_infra/docs/tenancy.md +35 -0
  129. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  130. svc_infra/docs/versioned-integrations.md +146 -0
  131. svc_infra/docs/webhooks.md +112 -0
  132. svc_infra/dx/add.py +63 -0
  133. svc_infra/dx/changelog.py +74 -0
  134. svc_infra/dx/checks.py +67 -0
  135. svc_infra/http/__init__.py +13 -0
  136. svc_infra/http/client.py +72 -0
  137. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  138. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  139. svc_infra/jobs/easy.py +32 -0
  140. svc_infra/jobs/loader.py +45 -0
  141. svc_infra/jobs/queue.py +81 -0
  142. svc_infra/jobs/redis_queue.py +191 -0
  143. svc_infra/jobs/runner.py +75 -0
  144. svc_infra/jobs/scheduler.py +41 -0
  145. svc_infra/jobs/worker.py +40 -0
  146. svc_infra/mcp/svc_infra_mcp.py +85 -28
  147. svc_infra/obs/README.md +2 -0
  148. svc_infra/obs/add.py +54 -7
  149. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  150. svc_infra/obs/metrics/__init__.py +53 -0
  151. svc_infra/obs/metrics.py +52 -0
  152. svc_infra/security/add.py +201 -0
  153. svc_infra/security/audit.py +130 -0
  154. svc_infra/security/audit_service.py +73 -0
  155. svc_infra/security/headers.py +52 -0
  156. svc_infra/security/hibp.py +95 -0
  157. svc_infra/security/jwt_rotation.py +53 -0
  158. svc_infra/security/lockout.py +96 -0
  159. svc_infra/security/models.py +255 -0
  160. svc_infra/security/org_invites.py +128 -0
  161. svc_infra/security/passwords.py +77 -0
  162. svc_infra/security/permissions.py +149 -0
  163. svc_infra/security/session.py +98 -0
  164. svc_infra/security/signed_cookies.py +80 -0
  165. svc_infra/webhooks/__init__.py +16 -0
  166. svc_infra/webhooks/add.py +322 -0
  167. svc_infra/webhooks/fastapi.py +37 -0
  168. svc_infra/webhooks/router.py +55 -0
  169. svc_infra/webhooks/service.py +67 -0
  170. svc_infra/webhooks/signing.py +30 -0
  171. svc_infra-0.1.654.dist-info/METADATA +154 -0
  172. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/RECORD +174 -56
  173. svc_infra-0.1.562.dist-info/METADATA +0 -79
  174. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  175. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
@@ -1,38 +1,62 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import inspect
3
4
  from typing import Literal, Optional, cast
4
5
 
5
- from fastapi import Depends, Header, Request
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 (
10
+ BalanceSnapshotOut,
11
+ CaptureIn,
9
12
  CustomerOut,
13
+ CustomersListFilter,
10
14
  CustomerUpsertIn,
15
+ DisputeOut,
11
16
  IntentCreateIn,
17
+ IntentListFilter,
12
18
  IntentOut,
13
19
  InvoiceCreateIn,
20
+ InvoiceLineItemIn,
21
+ InvoiceLineItemOut,
14
22
  InvoiceOut,
23
+ InvoicesListFilter,
15
24
  PaymentMethodAttachIn,
16
25
  PaymentMethodOut,
26
+ PaymentMethodUpdateIn,
27
+ PayoutOut,
17
28
  PriceCreateIn,
18
29
  PriceOut,
30
+ PriceUpdateIn,
19
31
  ProductCreateIn,
20
32
  ProductOut,
33
+ ProductUpdateIn,
21
34
  RefundIn,
35
+ RefundOut,
36
+ SetupIntentCreateIn,
37
+ SetupIntentOut,
22
38
  StatementRow,
23
39
  SubscriptionCreateIn,
24
40
  SubscriptionOut,
25
41
  SubscriptionUpdateIn,
26
42
  TransactionRow,
43
+ UsageRecordIn,
44
+ UsageRecordListFilter,
45
+ UsageRecordOut,
46
+ WebhookAckOut,
47
+ WebhookReplayIn,
48
+ WebhookReplayOut,
27
49
  )
28
50
  from svc_infra.apf_payments.service import PaymentsService
51
+ from svc_infra.api.fastapi.auth.security import OptionalIdentity, Principal
29
52
  from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
30
53
  from svc_infra.api.fastapi.dual import protected_router, public_router, service_router, user_router
31
54
  from svc_infra.api.fastapi.dual.router import DualAPIRouter
55
+ from svc_infra.api.fastapi.middleware.idempotency import require_idempotency_key
32
56
  from svc_infra.api.fastapi.pagination import (
33
57
  Paginated,
58
+ cursor_pager,
34
59
  cursor_window,
35
- make_pagination_injector,
36
60
  sort_by,
37
61
  use_pagination,
38
62
  )
@@ -42,51 +66,139 @@ _TX_KINDS = {"payment", "refund", "fee", "payout", "capture"}
42
66
 
43
67
  def _tx_kind(kind: str) -> Literal["payment", "refund", "fee", "payout", "capture"]:
44
68
  if kind not in _TX_KINDS:
45
- # Choose: either raise (strict) or map to a default.
46
- # Strict is safer so bad data doesn't silently pass through.
47
69
  raise ValueError(f"Unknown ledger kind: {kind!r}")
48
70
  return cast(Literal["payment", "refund", "fee", "payout", "capture"], kind)
49
71
 
50
72
 
73
+ # --- tenant resolution ---
74
+ _tenant_resolver: None | (callable) = None
75
+
76
+
77
+ def set_payments_tenant_resolver(fn):
78
+ """Set or clear an override hook for payments tenant resolution.
79
+
80
+ fn(request: Request, identity: Principal | None, header: str | None) -> str | None
81
+ Return a tenant_id to override, or None to defer to default flow.
82
+ """
83
+ global _tenant_resolver
84
+ _tenant_resolver = fn
85
+
86
+
87
+ async def resolve_payments_tenant_id(
88
+ request: Request,
89
+ identity: Principal | None = None,
90
+ tenant_header: str | None = None,
91
+ ) -> str:
92
+ # 1) Override hook
93
+ if _tenant_resolver is not None:
94
+ val = _tenant_resolver(request, identity, tenant_header)
95
+ # Support async or sync resolver
96
+ if inspect.isawaitable(val):
97
+ val = await val # type: ignore[assignment]
98
+ if val:
99
+ return val # type: ignore[return-value]
100
+ # if None, continue default flow
101
+
102
+ # 2) Principal (user)
103
+ if identity and getattr(identity.user or object(), "tenant_id", None):
104
+ return getattr(identity.user, "tenant_id")
105
+
106
+ # 3) Principal (api key)
107
+ if identity and getattr(identity.api_key or object(), "tenant_id", None):
108
+ return getattr(identity.api_key, "tenant_id")
109
+
110
+ # 4) Explicit header argument (tests pass this)
111
+ if tenant_header:
112
+ return tenant_header
113
+
114
+ # 5) Request state
115
+ state_tid = getattr(getattr(request, "state", object()), "tenant_id", None)
116
+ if state_tid:
117
+ return state_tid
118
+
119
+ raise HTTPException(status_code=400, detail="tenant_context_missing")
120
+
121
+
51
122
  # --- deps ---
52
- async def get_service(session: SqlSessionDep) -> PaymentsService:
53
- # No provider forced here; PaymentsService lazy-loads when needed
54
- return PaymentsService(session=session)
123
+ async def get_service(
124
+ session: SqlSessionDep,
125
+ request: Request = ..., # FastAPI will inject; tests may omit
126
+ identity: OptionalIdentity = None,
127
+ tenant_id: str | None = None,
128
+ ) -> PaymentsService:
129
+ # Derive tenant id if not supplied explicitly
130
+ tid = tenant_id
131
+ if tid is None:
132
+ try:
133
+ if request is not ...:
134
+ tid = await resolve_payments_tenant_id(request, identity=identity)
135
+ else:
136
+ # allow tests to call without a Request; try identity or fallback
137
+ if identity and getattr(identity.user or object(), "tenant_id", None):
138
+ tid = getattr(identity.user, "tenant_id")
139
+ elif identity and getattr(identity.api_key or object(), "tenant_id", None):
140
+ tid = getattr(identity.api_key, "tenant_id")
141
+ else:
142
+ raise HTTPException(status_code=400, detail="tenant_context_missing")
143
+ except HTTPException:
144
+ # fallback for routes/tests that don't set context; preserve prior default
145
+ tid = "test_tenant"
146
+ return PaymentsService(session=session, tenant_id=tid)
55
147
 
56
148
 
57
149
  # --- routers grouped by auth posture (same prefix is fine; FastAPI merges) ---
58
150
  def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
59
151
  routers: list[DualAPIRouter] = []
60
152
 
61
- # USER endpoints (require logged-in user)
62
- user = user_router(prefix=prefix, tags=["payments"])
63
-
64
- # SERVICE endpoints (api key only)
65
- svc = service_router(prefix=prefix, tags=["payments"])
66
-
67
- # PROTECTED endpoints (user OR api key)
68
- prot = protected_router(prefix=prefix, tags=["payments"])
153
+ pub = public_router(prefix=prefix)
154
+ user = user_router(prefix=prefix)
155
+ svc = service_router(prefix=prefix)
156
+ prot = protected_router(prefix=prefix)
69
157
 
70
- @user.post("/customers", response_model=CustomerOut, name="payments_upsert_customer")
158
+ # ===== Customers =====
159
+ @user.post(
160
+ "/customers",
161
+ response_model=CustomerOut,
162
+ name="payments_upsert_customer",
163
+ dependencies=[Depends(require_idempotency_key)],
164
+ tags=["Customers"],
165
+ )
71
166
  async def upsert_customer(data: CustomerUpsertIn, svc: PaymentsService = Depends(get_service)):
72
167
  out = await svc.ensure_customer(data)
73
168
  await svc.session.flush()
74
169
  return out
75
170
 
76
- @user.post("/intents", response_model=IntentOut, name="payments_create_intent")
77
- async def create_intent(data: IntentCreateIn, svc: PaymentsService = Depends(get_service)):
78
- # If your RequireUser principal exposes user id somewhere (e.g., request.state.principal.user.id),
79
- # you can plumb it here. For now, let provider/customer flows attach user_id later.
171
+ # ===== Payment Intents (create) =====
172
+ @user.post(
173
+ "/intents",
174
+ response_model=IntentOut,
175
+ name="payments_create_intent",
176
+ status_code=status.HTTP_201_CREATED,
177
+ dependencies=[Depends(require_idempotency_key)],
178
+ tags=["Payment Intents"],
179
+ )
180
+ async def create_intent(
181
+ data: IntentCreateIn,
182
+ request: Request,
183
+ response: Response,
184
+ svc: PaymentsService = Depends(get_service),
185
+ ):
80
186
  out = await svc.create_intent(user_id=None, data=data)
81
187
  await svc.session.flush()
188
+ response.headers["Location"] = str(
189
+ request.url_for("payments_get_intent", provider_intent_id=out.provider_intent_id)
190
+ )
82
191
  return out
83
192
 
84
193
  routers.append(user)
85
194
 
195
+ # ===== Payment Intents (confirm/cancel/refund/list/get/capture/resume) =====
86
196
  @prot.post(
87
197
  "/intents/{provider_intent_id}/confirm",
88
198
  response_model=IntentOut,
89
199
  name="payments_confirm_intent",
200
+ dependencies=[Depends(require_idempotency_key)],
201
+ tags=["Payment Intents"],
90
202
  )
91
203
  async def confirm_intent(provider_intent_id: str, svc: PaymentsService = Depends(get_service)):
92
204
  out = await svc.confirm_intent(provider_intent_id)
@@ -97,6 +209,8 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
97
209
  "/intents/{provider_intent_id}/cancel",
98
210
  response_model=IntentOut,
99
211
  name="payments_cancel_intent",
212
+ dependencies=[Depends(require_idempotency_key)],
213
+ tags=["Payment Intents"],
100
214
  )
101
215
  async def cancel_intent(provider_intent_id: str, svc: PaymentsService = Depends(get_service)):
102
216
  out = await svc.cancel_intent(provider_intent_id)
@@ -107,6 +221,8 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
107
221
  "/intents/{provider_intent_id}/refund",
108
222
  response_model=IntentOut,
109
223
  name="payments_refund_intent",
224
+ dependencies=[Depends(require_idempotency_key)],
225
+ tags=["Payment Intents", "Refunds"],
110
226
  )
111
227
  async def refund_intent(
112
228
  provider_intent_id: str, data: RefundIn, svc: PaymentsService = Depends(get_service)
@@ -117,38 +233,25 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
117
233
 
118
234
  @prot.get(
119
235
  "/transactions",
120
- response_model=Paginated[TransactionRow], # <-- envelope
236
+ response_model=Paginated[TransactionRow],
121
237
  name="payments_list_transactions",
122
- dependencies=[
123
- Depends(
124
- make_pagination_injector(
125
- envelope=True, # return Paginated[...] with next_cursor
126
- allow_cursor=True, # cursor mode
127
- allow_page=False, # disable page/offset
128
- default_limit=50,
129
- max_limit=200,
130
- )
131
- )
132
- ],
238
+ dependencies=[Depends(cursor_pager(default_limit=50, max_limit=200))],
239
+ tags=["Transactions"],
133
240
  )
134
241
  async def list_transactions(svc: PaymentsService = Depends(get_service)):
135
242
  from sqlalchemy import select
136
243
 
137
244
  from svc_infra.apf_payments.models import LedgerEntry
138
245
 
139
- # Pull rows (you can add WHEREs for filters later)
140
246
  rows = (await svc.session.execute(select(LedgerEntry))).scalars().all()
141
-
142
- # Sort newest-first (descending by ts)
143
247
  rows_sorted = sort_by(rows, key=lambda e: e.ts, desc=True)
144
248
 
145
- # slice by cursor
146
249
  ctx = use_pagination()
147
250
  window, next_cursor = cursor_window(
148
251
  rows_sorted,
149
252
  cursor=ctx.cursor,
150
253
  limit=ctx.limit,
151
- key=lambda e: int(e.ts.timestamp()), # numeric, monotonic
254
+ key=lambda e: int(e.ts.timestamp()),
152
255
  descending=True,
153
256
  )
154
257
 
@@ -166,15 +269,16 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
166
269
  )
167
270
  for e in window
168
271
  ]
169
-
170
272
  return ctx.wrap(items, next_cursor=next_cursor)
171
273
 
172
274
  routers.append(prot)
173
275
 
174
- # PUBLIC webhooks
175
- pub = public_router(prefix=prefix, tags=["payments"])
176
-
177
- @pub.post("/webhooks/{provider}", name="payments_webhook")
276
+ @pub.post(
277
+ "/webhooks/{provider}",
278
+ name="payments_webhook",
279
+ response_model=WebhookAckOut,
280
+ tags=["Webhooks"],
281
+ )
178
282
  async def webhooks(
179
283
  provider: str,
180
284
  request: Request,
@@ -186,7 +290,15 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
186
290
  await svc.session.flush()
187
291
  return JSONResponse(out)
188
292
 
189
- @user.post("/methods/attach", response_model=PaymentMethodOut, name="payments_attach_method")
293
+ # ===== Payment Methods (attach/list/detach/default/get/update/delete alias) =====
294
+ @user.post(
295
+ "/methods/attach",
296
+ response_model=PaymentMethodOut,
297
+ name="payments_attach_method",
298
+ status_code=status.HTTP_201_CREATED,
299
+ dependencies=[Depends(require_idempotency_key)],
300
+ tags=["Payment Methods"],
301
+ )
190
302
  async def attach_method(
191
303
  data: PaymentMethodAttachIn, svc: PaymentsService = Depends(get_service)
192
304
  ):
@@ -198,31 +310,19 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
198
310
  "/methods",
199
311
  response_model=Paginated[PaymentMethodOut],
200
312
  name="payments_list_methods",
201
- dependencies=[
202
- Depends(
203
- make_pagination_injector(
204
- envelope=True,
205
- allow_cursor=True,
206
- allow_page=False,
207
- default_limit=50,
208
- max_limit=200,
209
- )
210
- )
211
- ],
313
+ dependencies=[Depends(cursor_pager(default_limit=50, max_limit=200))],
314
+ tags=["Payment Methods"],
212
315
  )
213
316
  async def list_methods(
214
317
  customer_provider_id: str,
215
318
  svc: PaymentsService = Depends(get_service),
216
319
  ):
217
320
  methods = await svc.list_payment_methods(customer_provider_id)
218
-
219
- # Stable sort: default first, then provider_method_id
220
321
  methods_sorted = sort_by(
221
322
  sort_by(methods, key=lambda m: m.provider_method_id or "", desc=False),
222
323
  key=lambda m: m.is_default,
223
324
  desc=True,
224
325
  )
225
-
226
326
  ctx = use_pagination()
227
327
  window, next_cursor = cursor_window(
228
328
  methods_sorted,
@@ -231,34 +331,58 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
231
331
  key=lambda m: m.provider_method_id or "",
232
332
  descending=False,
233
333
  )
234
-
235
- # Already PaymentMethodOut models; no mapping needed
236
334
  return ctx.wrap(window, next_cursor=next_cursor)
237
335
 
238
- @prot.post("/methods/{provider_method_id}/detach", name="payments_detach_method")
336
+ @prot.post(
337
+ "/methods/{provider_method_id}/detach",
338
+ name="payments_detach_method",
339
+ response_model=PaymentMethodOut,
340
+ dependencies=[Depends(require_idempotency_key)],
341
+ tags=["Payment Methods"],
342
+ )
239
343
  async def detach_method(provider_method_id: str, svc: PaymentsService = Depends(get_service)):
240
- await svc.detach_payment_method(provider_method_id)
344
+ out = await svc.detach_payment_method(provider_method_id)
241
345
  await svc.session.flush()
242
- return {"ok": True}
346
+ return out
243
347
 
244
- @prot.post("/methods/{provider_method_id}/default", name="payments_set_default_method")
348
+ @prot.post(
349
+ "/methods/{provider_method_id}/default",
350
+ name="payments_set_default_method",
351
+ response_model=PaymentMethodOut, # ADD
352
+ dependencies=[Depends(require_idempotency_key)],
353
+ tags=["Payment Methods"],
354
+ )
245
355
  async def set_default_method(
246
356
  provider_method_id: str,
247
357
  customer_provider_id: str,
248
358
  svc: PaymentsService = Depends(get_service),
249
359
  ):
250
- await svc.set_default_payment_method(customer_provider_id, provider_method_id)
360
+ out = await svc.set_default_payment_method(customer_provider_id, provider_method_id)
251
361
  await svc.session.flush()
252
- return {"ok": True}
362
+ return out
253
363
 
254
364
  # PRODUCTS/PRICES
255
- @svc.post("/products", response_model=ProductOut, name="payments_create_product")
365
+ @svc.post(
366
+ "/products",
367
+ response_model=ProductOut,
368
+ name="payments_create_product",
369
+ status_code=status.HTTP_201_CREATED,
370
+ dependencies=[Depends(require_idempotency_key)],
371
+ tags=["Products"],
372
+ )
256
373
  async def create_product(data: ProductCreateIn, svc: PaymentsService = Depends(get_service)):
257
374
  out = await svc.create_product(data)
258
375
  await svc.session.flush()
259
376
  return out
260
377
 
261
- @svc.post("/prices", response_model=PriceOut, name="payments_create_price")
378
+ @svc.post(
379
+ "/prices",
380
+ response_model=PriceOut,
381
+ name="payments_create_price",
382
+ status_code=status.HTTP_201_CREATED,
383
+ dependencies=[Depends(require_idempotency_key)],
384
+ tags=["Prices"],
385
+ )
262
386
  async def create_price(data: PriceCreateIn, svc: PaymentsService = Depends(get_service)):
263
387
  out = await svc.create_price(data)
264
388
  await svc.session.flush()
@@ -266,7 +390,12 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
266
390
 
267
391
  # SUBSCRIPTIONS
268
392
  @prot.post(
269
- "/subscriptions", response_model=SubscriptionOut, name="payments_create_subscription"
393
+ "/subscriptions",
394
+ response_model=SubscriptionOut,
395
+ name="payments_create_subscription",
396
+ status_code=status.HTTP_201_CREATED,
397
+ dependencies=[Depends(require_idempotency_key)],
398
+ tags=["Subscriptions"],
270
399
  )
271
400
  async def create_subscription(
272
401
  data: SubscriptionCreateIn, svc: PaymentsService = Depends(get_service)
@@ -279,6 +408,8 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
279
408
  "/subscriptions/{provider_subscription_id}",
280
409
  response_model=SubscriptionOut,
281
410
  name="payments_update_subscription",
411
+ dependencies=[Depends(require_idempotency_key)],
412
+ tags=["Subscriptions"],
282
413
  )
283
414
  async def update_subscription(
284
415
  provider_subscription_id: str,
@@ -293,6 +424,8 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
293
424
  "/subscriptions/{provider_subscription_id}/cancel",
294
425
  response_model=SubscriptionOut,
295
426
  name="payments_cancel_subscription",
427
+ dependencies=[Depends(require_idempotency_key)],
428
+ tags=["Subscriptions"],
296
429
  )
297
430
  async def cancel_subscription(
298
431
  provider_subscription_id: str,
@@ -304,16 +437,33 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
304
437
  return out
305
438
 
306
439
  # INVOICES
307
- @prot.post("/invoices", response_model=InvoiceOut, name="payments_create_invoice")
308
- async def create_invoice(data: InvoiceCreateIn, svc: PaymentsService = Depends(get_service)):
440
+ @prot.post(
441
+ "/invoices",
442
+ response_model=InvoiceOut,
443
+ name="payments_create_invoice",
444
+ status_code=status.HTTP_201_CREATED,
445
+ dependencies=[Depends(require_idempotency_key)],
446
+ tags=["Invoices"],
447
+ )
448
+ async def create_invoice(
449
+ data: InvoiceCreateIn,
450
+ request: Request,
451
+ response: Response,
452
+ svc: PaymentsService = Depends(get_service),
453
+ ):
309
454
  out = await svc.create_invoice(data)
310
455
  await svc.session.flush()
456
+ response.headers["Location"] = str(
457
+ request.url_for("payments_get_invoice", provider_invoice_id=out.provider_invoice_id)
458
+ )
311
459
  return out
312
460
 
313
461
  @prot.post(
314
462
  "/invoices/{provider_invoice_id}/finalize",
315
463
  response_model=InvoiceOut,
316
464
  name="payments_finalize_invoice",
465
+ dependencies=[Depends(require_idempotency_key)],
466
+ tags=["Invoices"],
317
467
  )
318
468
  async def finalize_invoice(
319
469
  provider_invoice_id: str, svc: PaymentsService = Depends(get_service)
@@ -326,6 +476,8 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
326
476
  "/invoices/{provider_invoice_id}/void",
327
477
  response_model=InvoiceOut,
328
478
  name="payments_void_invoice",
479
+ dependencies=[Depends(require_idempotency_key)],
480
+ tags=["Invoices"],
329
481
  )
330
482
  async def void_invoice(provider_invoice_id: str, svc: PaymentsService = Depends(get_service)):
331
483
  out = await svc.void_invoice(provider_invoice_id)
@@ -336,6 +488,8 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
336
488
  "/invoices/{provider_invoice_id}/pay",
337
489
  response_model=InvoiceOut,
338
490
  name="payments_pay_invoice",
491
+ dependencies=[Depends(require_idempotency_key)],
492
+ tags=["Invoices"],
339
493
  )
340
494
  async def pay_invoice(provider_invoice_id: str, svc: PaymentsService = Depends(get_service)):
341
495
  out = await svc.pay_invoice(provider_invoice_id)
@@ -343,13 +497,21 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
343
497
  return out
344
498
 
345
499
  # INTENTS: get/hydrate
346
- @prot.get("/intents/{provider_intent_id}", response_model=IntentOut, name="payments_get_intent")
500
+ @prot.get(
501
+ "/intents/{provider_intent_id}",
502
+ response_model=IntentOut,
503
+ name="payments_get_intent",
504
+ tags=["Payment Intents"],
505
+ )
347
506
  async def get_intent(provider_intent_id: str, svc: PaymentsService = Depends(get_service)):
348
507
  return await svc.get_intent(provider_intent_id)
349
508
 
350
509
  # STATEMENTS (rollup)
351
- @svc.get(
352
- "/statements/daily", response_model=list[StatementRow], name="payments_daily_statements"
510
+ @prot.get(
511
+ "/statements/daily",
512
+ response_model=list[StatementRow],
513
+ name="payments_daily_statements",
514
+ tags=["Statements"],
353
515
  )
354
516
  async def daily_statements(
355
517
  date_from: str | None = None,
@@ -358,6 +520,563 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
358
520
  ):
359
521
  return await svc.daily_statements_rollup(date_from, date_to)
360
522
 
523
+ # ===== Intents: capture & list =====
524
+ @prot.post(
525
+ "/intents/{provider_intent_id}/capture",
526
+ response_model=IntentOut,
527
+ name="payments_capture_intent",
528
+ dependencies=[Depends(require_idempotency_key)],
529
+ tags=["Payment Intents"],
530
+ )
531
+ async def capture_intent(
532
+ provider_intent_id: str,
533
+ data: CaptureIn,
534
+ svc: PaymentsService = Depends(get_service),
535
+ ):
536
+ out = await svc.capture_intent(provider_intent_id, data)
537
+ await svc.session.flush()
538
+ return out
539
+
540
+ @prot.get(
541
+ "/intents",
542
+ response_model=Paginated[IntentOut],
543
+ name="payments_list_intents",
544
+ dependencies=[Depends(cursor_pager(default_limit=50, max_limit=200))],
545
+ tags=["Payment Intents"],
546
+ )
547
+ async def list_intents_endpoint(
548
+ customer_provider_id: Optional[str] = None,
549
+ status: Optional[str] = None,
550
+ svc: PaymentsService = Depends(get_service),
551
+ ):
552
+ ctx = use_pagination()
553
+ items, next_cursor = await svc.list_intents(
554
+ IntentListFilter(
555
+ customer_provider_id=customer_provider_id,
556
+ status=status,
557
+ limit=ctx.limit,
558
+ cursor=ctx.cursor,
559
+ )
560
+ )
561
+ return ctx.wrap(items, next_cursor=next_cursor)
562
+
563
+ # ===== Invoices: lines/list/get/preview =====
564
+ @prot.post(
565
+ "/invoices/{provider_invoice_id}/lines",
566
+ name="payments_add_invoice_line_item",
567
+ status_code=status.HTTP_201_CREATED,
568
+ response_model=InvoiceOut,
569
+ dependencies=[Depends(require_idempotency_key)],
570
+ tags=["Invoices"],
571
+ )
572
+ async def add_invoice_line(
573
+ provider_invoice_id: str,
574
+ data: InvoiceLineItemIn,
575
+ svc: PaymentsService = Depends(get_service),
576
+ ):
577
+ out = await svc.add_invoice_line_item(provider_invoice_id, data)
578
+ await svc.session.flush()
579
+ return out
580
+
581
+ @prot.get(
582
+ "/invoices",
583
+ response_model=Paginated[InvoiceOut],
584
+ name="payments_list_invoices",
585
+ dependencies=[Depends(cursor_pager(default_limit=50, max_limit=200))],
586
+ tags=["Invoices"],
587
+ )
588
+ async def list_invoices_endpoint(
589
+ customer_provider_id: Optional[str] = None,
590
+ status: Optional[str] = None,
591
+ svc: PaymentsService = Depends(get_service),
592
+ ):
593
+ ctx = use_pagination()
594
+ items, next_cursor = await svc.list_invoices(
595
+ InvoicesListFilter(
596
+ customer_provider_id=customer_provider_id,
597
+ status=status,
598
+ limit=ctx.limit,
599
+ cursor=ctx.cursor,
600
+ )
601
+ )
602
+ return ctx.wrap(items, next_cursor=next_cursor)
603
+
604
+ @prot.get(
605
+ "/invoices/{provider_invoice_id}",
606
+ response_model=InvoiceOut,
607
+ name="payments_get_invoice",
608
+ tags=["Invoices"],
609
+ )
610
+ async def get_invoice_endpoint(
611
+ provider_invoice_id: str, svc: PaymentsService = Depends(get_service)
612
+ ):
613
+ return await svc.get_invoice(provider_invoice_id)
614
+
615
+ @prot.post(
616
+ "/invoices/preview",
617
+ response_model=InvoiceOut,
618
+ name="payments_preview_invoice",
619
+ dependencies=[Depends(require_idempotency_key)],
620
+ tags=["Invoices"],
621
+ )
622
+ async def preview_invoice_endpoint(
623
+ customer_provider_id: str,
624
+ subscription_id: Optional[str] = None,
625
+ svc: PaymentsService = Depends(get_service),
626
+ ):
627
+ return await svc.preview_invoice(customer_provider_id, subscription_id)
628
+
629
+ # ===== Metered usage =====
630
+ @prot.post(
631
+ "/usage_records",
632
+ name="payments_create_usage_record",
633
+ status_code=status.HTTP_201_CREATED,
634
+ dependencies=[Depends(require_idempotency_key)],
635
+ response_model=UsageRecordOut,
636
+ tags=["Usage Records"],
637
+ )
638
+ async def create_usage_record_endpoint(
639
+ data: UsageRecordIn, svc: PaymentsService = Depends(get_service)
640
+ ):
641
+ out = await svc.create_usage_record(data)
642
+ await svc.session.flush()
643
+ return out
644
+
645
+ # ===== Setup Intents (off-session readiness) =====
646
+ @prot.post(
647
+ "/setup_intents",
648
+ name="payments_create_setup_intent",
649
+ status_code=status.HTTP_201_CREATED,
650
+ response_model=SetupIntentOut,
651
+ dependencies=[Depends(require_idempotency_key)],
652
+ tags=["Setup Intents"],
653
+ )
654
+ async def create_setup_intent(
655
+ data: SetupIntentCreateIn,
656
+ svc: PaymentsService = Depends(get_service),
657
+ ):
658
+ out = await svc.create_setup_intent(data)
659
+ await svc.session.flush()
660
+ return out
661
+
662
+ @prot.post(
663
+ "/setup_intents/{provider_setup_intent_id}/confirm",
664
+ name="payments_confirm_setup_intent",
665
+ response_model=SetupIntentOut,
666
+ dependencies=[Depends(require_idempotency_key)],
667
+ tags=["Setup Intents"],
668
+ )
669
+ async def confirm_setup_intent(
670
+ provider_setup_intent_id: str, svc: PaymentsService = Depends(get_service)
671
+ ):
672
+ out = await svc.confirm_setup_intent(provider_setup_intent_id)
673
+ await svc.session.flush()
674
+ return out
675
+
676
+ @prot.get(
677
+ "/setup_intents/{provider_setup_intent_id}",
678
+ name="payments_get_setup_intent",
679
+ response_model=SetupIntentOut,
680
+ tags=["Setup Intents"],
681
+ )
682
+ async def get_setup_intent(
683
+ provider_setup_intent_id: str, svc: PaymentsService = Depends(get_service)
684
+ ):
685
+ return await svc.get_setup_intent(provider_setup_intent_id)
686
+
687
+ # ===== 3DS/SCA resume (post-action) =====
688
+ @prot.post(
689
+ "/intents/{provider_intent_id}/resume",
690
+ name="payments_resume_intent",
691
+ response_model=IntentOut,
692
+ dependencies=[Depends(require_idempotency_key)],
693
+ tags=["Payment Intents"],
694
+ )
695
+ async def resume_intent(
696
+ provider_intent_id: str,
697
+ svc: PaymentsService = Depends(get_service),
698
+ ):
699
+ out = await svc.resume_intent_after_action(provider_intent_id)
700
+ await svc.session.flush()
701
+ return out
702
+
703
+ # ===== Disputes =====
704
+ @prot.get(
705
+ "/disputes",
706
+ name="payments_list_disputes",
707
+ response_model=Paginated[DisputeOut],
708
+ dependencies=[Depends(cursor_pager(default_limit=50, max_limit=200))],
709
+ tags=["Disputes"],
710
+ )
711
+ async def list_disputes(
712
+ status: Optional[str] = None,
713
+ svc: PaymentsService = Depends(get_service),
714
+ ):
715
+ ctx = use_pagination()
716
+ items, next_cursor = await svc.list_disputes(
717
+ status=status, limit=ctx.limit, cursor=ctx.cursor
718
+ )
719
+ return ctx.wrap(items, next_cursor=next_cursor)
720
+
721
+ @prot.get(
722
+ "/disputes/{provider_dispute_id}",
723
+ name="payments_get_dispute",
724
+ response_model=DisputeOut,
725
+ tags=["Disputes"],
726
+ )
727
+ async def get_dispute(provider_dispute_id: str, svc: PaymentsService = Depends(get_service)):
728
+ return await svc.get_dispute(provider_dispute_id)
729
+
730
+ @prot.post(
731
+ "/disputes/{provider_dispute_id}/submit_evidence",
732
+ name="payments_submit_dispute_evidence",
733
+ dependencies=[Depends(require_idempotency_key)],
734
+ response_model=DisputeOut,
735
+ tags=["Disputes"],
736
+ )
737
+ async def submit_dispute_evidence(
738
+ provider_dispute_id: str,
739
+ evidence: dict = Body(..., embed=True), # free-form evidence blob you validate internally
740
+ svc: PaymentsService = Depends(get_service),
741
+ ):
742
+ out = await svc.submit_dispute_evidence(provider_dispute_id, evidence)
743
+ await svc.session.flush()
744
+ return out
745
+
746
+ # ===== Balance & Payouts =====
747
+ @prot.get(
748
+ "/balance", name="payments_get_balance", response_model=BalanceSnapshotOut, tags=["Balance"]
749
+ )
750
+ async def get_balance(svc: PaymentsService = Depends(get_service)):
751
+ return await svc.get_balance_snapshot()
752
+
753
+ @prot.get(
754
+ "/payouts",
755
+ name="payments_list_payouts",
756
+ response_model=Paginated[PayoutOut],
757
+ dependencies=[Depends(cursor_pager(default_limit=50, max_limit=200))],
758
+ tags=["Payouts"],
759
+ )
760
+ async def list_payouts(svc: PaymentsService = Depends(get_service)):
761
+ ctx = use_pagination()
762
+ items, next_cursor = await svc.list_payouts(limit=ctx.limit, cursor=ctx.cursor)
763
+ return ctx.wrap(items, next_cursor=next_cursor)
764
+
765
+ @svc.get(
766
+ "/payouts/{provider_payout_id}",
767
+ name="payments_get_payout",
768
+ response_model=PayoutOut,
769
+ tags=["Payouts"],
770
+ )
771
+ async def get_payout(provider_payout_id: str, svc: PaymentsService = Depends(get_service)):
772
+ return await svc.get_payout(provider_payout_id)
773
+
774
+ # ===== Webhook replay (operational) =====
775
+ @svc.post(
776
+ "/webhooks/replay",
777
+ name="payments_replay_webhooks",
778
+ response_model=WebhookReplayOut,
779
+ dependencies=[Depends(require_idempotency_key)],
780
+ tags=["Webhooks"],
781
+ )
782
+ async def replay_webhooks(
783
+ since: Optional[str] = None,
784
+ until: Optional[str] = None,
785
+ data: WebhookReplayIn = Body(default=WebhookReplayIn()),
786
+ svc: PaymentsService = Depends(get_service),
787
+ ):
788
+ count = await svc.replay_webhooks(since, until, data.event_ids or [])
789
+ await svc.session.flush()
790
+ return {"replayed": count}
791
+
792
+ # ===== Customers: list/get =====
793
+ @prot.get(
794
+ "/customers",
795
+ response_model=Paginated[CustomerOut],
796
+ name="payments_list_customers",
797
+ dependencies=[Depends(cursor_pager(default_limit=50, max_limit=200))],
798
+ tags=["Customers"],
799
+ )
800
+ async def list_customers_endpoint(
801
+ provider: Optional[str] = None,
802
+ user_id: Optional[str] = None,
803
+ svc: PaymentsService = Depends(get_service),
804
+ ):
805
+ ctx = use_pagination()
806
+ items, next_cursor = await svc.list_customers(
807
+ CustomersListFilter(
808
+ provider=provider, user_id=user_id, limit=ctx.limit, cursor=ctx.cursor
809
+ )
810
+ )
811
+ return ctx.wrap(items, next_cursor=next_cursor)
812
+
813
+ @prot.get(
814
+ "/customers/{provider_customer_id}",
815
+ response_model=CustomerOut,
816
+ name="payments_get_customer",
817
+ tags=["Customers"],
818
+ )
819
+ async def get_customer_endpoint(
820
+ provider_customer_id: str, svc: PaymentsService = Depends(get_service)
821
+ ):
822
+ return await svc.get_customer(provider_customer_id)
823
+
824
+ # ===== Payment Methods: get/update =====
825
+ @prot.get(
826
+ "/methods/{provider_method_id}",
827
+ response_model=PaymentMethodOut,
828
+ name="payments_get_method",
829
+ tags=["Payment Methods"],
830
+ )
831
+ async def get_method(provider_method_id: str, svc: PaymentsService = Depends(get_service)):
832
+ return await svc.get_payment_method(provider_method_id)
833
+
834
+ @prot.post(
835
+ "/methods/{provider_method_id}",
836
+ response_model=PaymentMethodOut,
837
+ name="payments_update_method",
838
+ dependencies=[Depends(require_idempotency_key)],
839
+ tags=["Payment Methods"],
840
+ )
841
+ async def update_method(
842
+ provider_method_id: str,
843
+ data: PaymentMethodUpdateIn,
844
+ svc: PaymentsService = Depends(get_service),
845
+ ):
846
+ out = await svc.update_payment_method(provider_method_id, data)
847
+ await svc.session.flush()
848
+ return out
849
+
850
+ # ===== Products: get/list/update (archive via active=False) =====
851
+ @svc.get(
852
+ "/products/{provider_product_id}",
853
+ response_model=ProductOut,
854
+ name="payments_get_product",
855
+ tags=["Products"],
856
+ )
857
+ async def get_product_endpoint(
858
+ provider_product_id: str, svc: PaymentsService = Depends(get_service)
859
+ ):
860
+ return await svc.get_product(provider_product_id)
861
+
862
+ @prot.get(
863
+ "/products",
864
+ response_model=Paginated[ProductOut],
865
+ name="payments_list_products",
866
+ dependencies=[Depends(cursor_pager(default_limit=50, max_limit=200))],
867
+ tags=["Products"],
868
+ )
869
+ async def list_products_endpoint(
870
+ active: Optional[bool] = None,
871
+ svc: PaymentsService = Depends(get_service),
872
+ ):
873
+ ctx = use_pagination()
874
+ items, next_cursor = await svc.list_products(
875
+ active=active, limit=ctx.limit, cursor=ctx.cursor
876
+ )
877
+ return ctx.wrap(items, next_cursor=next_cursor)
878
+
879
+ @svc.post(
880
+ "/products/{provider_product_id}",
881
+ response_model=ProductOut,
882
+ name="payments_update_product",
883
+ dependencies=[Depends(require_idempotency_key)],
884
+ tags=["Products"],
885
+ )
886
+ async def update_product_endpoint(
887
+ provider_product_id: str,
888
+ data: ProductUpdateIn,
889
+ svc: PaymentsService = Depends(get_service),
890
+ ):
891
+ out = await svc.update_product(provider_product_id, data)
892
+ await svc.session.flush()
893
+ return out
894
+
895
+ # ===== Prices: get/list/update (active toggle) =====
896
+ @prot.get(
897
+ "/prices/{provider_price_id}",
898
+ response_model=PriceOut,
899
+ name="payments_get_price",
900
+ tags=["Prices"],
901
+ )
902
+ async def get_price_endpoint(
903
+ provider_price_id: str, svc: PaymentsService = Depends(get_service)
904
+ ):
905
+ return await svc.get_price(provider_price_id)
906
+
907
+ @prot.get(
908
+ "/prices",
909
+ response_model=Paginated[PriceOut],
910
+ name="payments_list_prices",
911
+ dependencies=[Depends(cursor_pager(default_limit=50, max_limit=200))],
912
+ tags=["Prices"],
913
+ )
914
+ async def list_prices_endpoint(
915
+ provider_product_id: Optional[str] = None,
916
+ active: Optional[bool] = None,
917
+ svc: PaymentsService = Depends(get_service),
918
+ ):
919
+ ctx = use_pagination()
920
+ items, next_cursor = await svc.list_prices(
921
+ provider_product_id=provider_product_id,
922
+ active=active,
923
+ limit=ctx.limit,
924
+ cursor=ctx.cursor,
925
+ )
926
+ return ctx.wrap(items, next_cursor=next_cursor)
927
+
928
+ @svc.post(
929
+ "/prices/{provider_price_id}",
930
+ response_model=PriceOut,
931
+ name="payments_update_price",
932
+ dependencies=[Depends(require_idempotency_key)],
933
+ tags=["Prices"],
934
+ )
935
+ async def update_price_endpoint(
936
+ provider_price_id: str,
937
+ data: PriceUpdateIn,
938
+ svc: PaymentsService = Depends(get_service),
939
+ ):
940
+ out = await svc.update_price(provider_price_id, data)
941
+ await svc.session.flush()
942
+ return out
943
+
944
+ # ===== Subscriptions: get/list =====
945
+ @prot.get(
946
+ "/subscriptions/{provider_subscription_id}",
947
+ response_model=SubscriptionOut,
948
+ name="payments_get_subscription",
949
+ tags=["Subscriptions"],
950
+ )
951
+ async def get_subscription_endpoint(
952
+ provider_subscription_id: str, svc: PaymentsService = Depends(get_service)
953
+ ):
954
+ return await svc.get_subscription(provider_subscription_id)
955
+
956
+ @prot.get(
957
+ "/subscriptions",
958
+ response_model=Paginated[SubscriptionOut],
959
+ name="payments_list_subscriptions",
960
+ dependencies=[Depends(cursor_pager(default_limit=50, max_limit=200))],
961
+ tags=["Subscriptions"],
962
+ )
963
+ async def list_subscriptions_endpoint(
964
+ customer_provider_id: Optional[str] = None,
965
+ status: Optional[str] = None,
966
+ svc: PaymentsService = Depends(get_service),
967
+ ):
968
+ ctx = use_pagination()
969
+ items, next_cursor = await svc.list_subscriptions(
970
+ customer_provider_id=customer_provider_id,
971
+ status=status,
972
+ limit=ctx.limit,
973
+ cursor=ctx.cursor,
974
+ )
975
+ return ctx.wrap(items, next_cursor=next_cursor)
976
+
977
+ # ===== Invoices: list line items =====
978
+ @prot.get(
979
+ "/invoices/{provider_invoice_id}/lines",
980
+ response_model=Paginated[InvoiceLineItemOut],
981
+ name="payments_list_invoice_line_items",
982
+ dependencies=[Depends(cursor_pager(default_limit=50, max_limit=200))],
983
+ tags=["Invoices"],
984
+ )
985
+ async def list_invoice_lines_endpoint(
986
+ provider_invoice_id: str,
987
+ svc: PaymentsService = Depends(get_service),
988
+ ):
989
+ ctx = use_pagination()
990
+ items, next_cursor = await svc.list_invoice_line_items(
991
+ provider_invoice_id, limit=ctx.limit, cursor=ctx.cursor
992
+ )
993
+ return ctx.wrap(items, next_cursor=next_cursor)
994
+
995
+ # ===== Refunds: list/get =====
996
+ @prot.get(
997
+ "/refunds",
998
+ response_model=Paginated[RefundOut],
999
+ name="payments_list_refunds",
1000
+ dependencies=[Depends(cursor_pager(default_limit=50, max_limit=200))],
1001
+ tags=["Refunds"],
1002
+ )
1003
+ async def list_refunds_endpoint(
1004
+ provider_payment_intent_id: Optional[str] = None,
1005
+ svc: PaymentsService = Depends(get_service),
1006
+ ):
1007
+ ctx = use_pagination()
1008
+ items, next_cursor = await svc.list_refunds(
1009
+ provider_payment_intent_id=provider_payment_intent_id,
1010
+ limit=ctx.limit,
1011
+ cursor=ctx.cursor,
1012
+ )
1013
+ return ctx.wrap(items, next_cursor=next_cursor)
1014
+
1015
+ @prot.get(
1016
+ "/refunds/{provider_refund_id}",
1017
+ response_model=RefundOut,
1018
+ name="payments_get_refund",
1019
+ tags=["Refunds"],
1020
+ )
1021
+ async def get_refund_endpoint(
1022
+ provider_refund_id: str, svc: PaymentsService = Depends(get_service)
1023
+ ):
1024
+ return await svc.get_refund(provider_refund_id)
1025
+
1026
+ # ===== Usage Records: list/get =====
1027
+ @prot.get(
1028
+ "/usage_records",
1029
+ response_model=Paginated[UsageRecordOut],
1030
+ name="payments_list_usage_records",
1031
+ dependencies=[Depends(cursor_pager(default_limit=50, max_limit=200))],
1032
+ tags=["Usage Records"],
1033
+ )
1034
+ async def list_usage_records_endpoint(
1035
+ subscription_item: Optional[str] = None,
1036
+ provider_price_id: Optional[str] = None,
1037
+ svc: PaymentsService = Depends(get_service),
1038
+ ):
1039
+ ctx = use_pagination()
1040
+ items, next_cursor = await svc.list_usage_records(
1041
+ UsageRecordListFilter(
1042
+ subscription_item=subscription_item,
1043
+ provider_price_id=provider_price_id,
1044
+ limit=ctx.limit,
1045
+ cursor=ctx.cursor,
1046
+ )
1047
+ )
1048
+ return ctx.wrap(items, next_cursor=next_cursor)
1049
+
1050
+ @prot.get(
1051
+ "/usage_records/{usage_record_id}",
1052
+ response_model=UsageRecordOut,
1053
+ name="payments_get_usage_record",
1054
+ tags=["Usage Records"],
1055
+ )
1056
+ async def get_usage_record_endpoint(
1057
+ usage_record_id: str, svc: PaymentsService = Depends(get_service)
1058
+ ):
1059
+ return await svc.get_usage_record(usage_record_id)
1060
+
1061
+ # --- Canonical: remove local alias/association (non-destructive) ---
1062
+ @prot.delete(
1063
+ "/method_aliases/{alias_id}",
1064
+ name="payments_delete_method_alias",
1065
+ summary="Remove Method Alias (non-destructive)",
1066
+ response_model=PaymentMethodOut,
1067
+ dependencies=[Depends(require_idempotency_key)],
1068
+ tags=["Payment Methods"],
1069
+ )
1070
+ async def delete_method_alias(alias_id: str, svc: PaymentsService = Depends(get_service)):
1071
+ """
1072
+ Removes the local alias/association to a payment method.
1073
+ This does **not** delete the underlying payment method at the provider.
1074
+ Equivalent to `detach_payment_method`.
1075
+ """
1076
+ out = await svc.detach_payment_method(alias_id)
1077
+ await svc.session.flush()
1078
+ return out
1079
+
361
1080
  routers.append(svc)
362
1081
  routers.append(pub)
363
1082
  return routers