svc-infra 0.1.506__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 (202) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/alembic.py +11 -0
  3. svc_infra/apf_payments/models.py +339 -0
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +797 -0
  6. svc_infra/apf_payments/provider/base.py +270 -0
  7. svc_infra/apf_payments/provider/registry.py +31 -0
  8. svc_infra/apf_payments/provider/stripe.py +873 -0
  9. svc_infra/apf_payments/schemas.py +333 -0
  10. svc_infra/apf_payments/service.py +892 -0
  11. svc_infra/apf_payments/settings.py +67 -0
  12. svc_infra/api/fastapi/__init__.py +6 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +231 -0
  15. svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
  16. svc_infra/api/fastapi/apf_payments/router.py +1082 -0
  17. svc_infra/api/fastapi/apf_payments/setup.py +73 -0
  18. svc_infra/api/fastapi/auth/add.py +15 -6
  19. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  20. svc_infra/api/fastapi/auth/mfa/router.py +18 -9
  21. svc_infra/api/fastapi/auth/routers/account.py +3 -2
  22. svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
  23. svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
  24. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  25. svc_infra/api/fastapi/auth/security.py +3 -1
  26. svc_infra/api/fastapi/auth/settings.py +2 -0
  27. svc_infra/api/fastapi/auth/state.py +1 -1
  28. svc_infra/api/fastapi/billing/router.py +64 -0
  29. svc_infra/api/fastapi/billing/setup.py +19 -0
  30. svc_infra/api/fastapi/cache/add.py +9 -5
  31. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  32. svc_infra/api/fastapi/db/sql/add.py +40 -18
  33. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  34. svc_infra/api/fastapi/db/sql/session.py +16 -0
  35. svc_infra/api/fastapi/db/sql/users.py +14 -2
  36. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  37. svc_infra/api/fastapi/docs/add.py +160 -0
  38. svc_infra/api/fastapi/docs/landing.py +1 -1
  39. svc_infra/api/fastapi/docs/scoped.py +254 -0
  40. svc_infra/api/fastapi/dual/dualize.py +38 -33
  41. svc_infra/api/fastapi/dual/router.py +48 -1
  42. svc_infra/api/fastapi/dx.py +3 -3
  43. svc_infra/api/fastapi/http/__init__.py +0 -0
  44. svc_infra/api/fastapi/http/concurrency.py +14 -0
  45. svc_infra/api/fastapi/http/conditional.py +33 -0
  46. svc_infra/api/fastapi/http/deprecation.py +21 -0
  47. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  48. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  49. svc_infra/api/fastapi/middleware/idempotency.py +116 -0
  50. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  51. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  52. svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
  53. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  54. svc_infra/api/fastapi/middleware/request_id.py +23 -0
  55. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  56. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  57. svc_infra/api/fastapi/openapi/mutators.py +768 -55
  58. svc_infra/api/fastapi/ops/add.py +73 -0
  59. svc_infra/api/fastapi/pagination.py +363 -0
  60. svc_infra/api/fastapi/paths/auth.py +14 -14
  61. svc_infra/api/fastapi/paths/prefix.py +0 -1
  62. svc_infra/api/fastapi/paths/user.py +1 -1
  63. svc_infra/api/fastapi/routers/ping.py +1 -0
  64. svc_infra/api/fastapi/setup.py +48 -15
  65. svc_infra/api/fastapi/tenancy/add.py +19 -0
  66. svc_infra/api/fastapi/tenancy/context.py +112 -0
  67. svc_infra/api/fastapi/versioned.py +101 -0
  68. svc_infra/app/README.md +5 -5
  69. svc_infra/billing/__init__.py +23 -0
  70. svc_infra/billing/async_service.py +147 -0
  71. svc_infra/billing/jobs.py +230 -0
  72. svc_infra/billing/models.py +131 -0
  73. svc_infra/billing/quotas.py +101 -0
  74. svc_infra/billing/schemas.py +33 -0
  75. svc_infra/billing/service.py +115 -0
  76. svc_infra/bundled_docs/README.md +5 -0
  77. svc_infra/bundled_docs/__init__.py +1 -0
  78. svc_infra/bundled_docs/getting-started.md +6 -0
  79. svc_infra/cache/__init__.py +4 -0
  80. svc_infra/cache/add.py +158 -0
  81. svc_infra/cache/backend.py +5 -2
  82. svc_infra/cache/decorators.py +19 -1
  83. svc_infra/cache/keys.py +24 -4
  84. svc_infra/cli/__init__.py +32 -8
  85. svc_infra/cli/__main__.py +4 -0
  86. svc_infra/cli/cmds/__init__.py +10 -0
  87. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  88. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  89. svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
  90. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  91. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
  92. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  93. svc_infra/cli/cmds/dx/__init__.py +12 -0
  94. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  95. svc_infra/cli/cmds/help.py +4 -0
  96. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  97. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  98. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  99. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  100. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  101. svc_infra/data/add.py +61 -0
  102. svc_infra/data/backup.py +53 -0
  103. svc_infra/data/erasure.py +45 -0
  104. svc_infra/data/fixtures.py +40 -0
  105. svc_infra/data/retention.py +55 -0
  106. svc_infra/db/inbox.py +67 -0
  107. svc_infra/db/nosql/mongo/README.md +13 -13
  108. svc_infra/db/outbox.py +104 -0
  109. svc_infra/db/sql/apikey.py +1 -1
  110. svc_infra/db/sql/authref.py +61 -0
  111. svc_infra/db/sql/core.py +2 -2
  112. svc_infra/db/sql/repository.py +52 -12
  113. svc_infra/db/sql/resource.py +5 -0
  114. svc_infra/db/sql/scaffold.py +16 -4
  115. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  116. svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
  117. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
  118. svc_infra/db/sql/tenant.py +79 -0
  119. svc_infra/db/sql/utils.py +18 -4
  120. svc_infra/db/sql/versioning.py +14 -0
  121. svc_infra/docs/acceptance-matrix.md +71 -0
  122. svc_infra/docs/acceptance.md +44 -0
  123. svc_infra/docs/admin.md +425 -0
  124. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  125. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  126. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  127. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  128. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  129. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  130. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  131. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  132. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  133. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  134. svc_infra/docs/api.md +59 -0
  135. svc_infra/docs/auth.md +11 -0
  136. svc_infra/docs/billing.md +190 -0
  137. svc_infra/docs/cache.md +76 -0
  138. svc_infra/docs/cli.md +74 -0
  139. svc_infra/docs/contributing.md +34 -0
  140. svc_infra/docs/data-lifecycle.md +52 -0
  141. svc_infra/docs/database.md +14 -0
  142. svc_infra/docs/docs-and-sdks.md +62 -0
  143. svc_infra/docs/environment.md +114 -0
  144. svc_infra/docs/getting-started.md +63 -0
  145. svc_infra/docs/idempotency.md +111 -0
  146. svc_infra/docs/jobs.md +67 -0
  147. svc_infra/docs/observability.md +16 -0
  148. svc_infra/docs/ops.md +37 -0
  149. svc_infra/docs/rate-limiting.md +125 -0
  150. svc_infra/docs/repo-review.md +48 -0
  151. svc_infra/docs/security.md +176 -0
  152. svc_infra/docs/tenancy.md +35 -0
  153. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  154. svc_infra/docs/versioned-integrations.md +146 -0
  155. svc_infra/docs/webhooks.md +112 -0
  156. svc_infra/dx/add.py +63 -0
  157. svc_infra/dx/changelog.py +74 -0
  158. svc_infra/dx/checks.py +67 -0
  159. svc_infra/http/__init__.py +13 -0
  160. svc_infra/http/client.py +72 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  162. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  163. svc_infra/jobs/easy.py +32 -0
  164. svc_infra/jobs/loader.py +45 -0
  165. svc_infra/jobs/queue.py +81 -0
  166. svc_infra/jobs/redis_queue.py +191 -0
  167. svc_infra/jobs/runner.py +75 -0
  168. svc_infra/jobs/scheduler.py +41 -0
  169. svc_infra/jobs/worker.py +40 -0
  170. svc_infra/mcp/svc_infra_mcp.py +85 -28
  171. svc_infra/obs/README.md +2 -0
  172. svc_infra/obs/add.py +54 -7
  173. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  174. svc_infra/obs/metrics/__init__.py +53 -0
  175. svc_infra/obs/metrics.py +52 -0
  176. svc_infra/security/add.py +201 -0
  177. svc_infra/security/audit.py +130 -0
  178. svc_infra/security/audit_service.py +73 -0
  179. svc_infra/security/headers.py +52 -0
  180. svc_infra/security/hibp.py +95 -0
  181. svc_infra/security/jwt_rotation.py +53 -0
  182. svc_infra/security/lockout.py +96 -0
  183. svc_infra/security/models.py +255 -0
  184. svc_infra/security/org_invites.py +128 -0
  185. svc_infra/security/passwords.py +77 -0
  186. svc_infra/security/permissions.py +149 -0
  187. svc_infra/security/session.py +98 -0
  188. svc_infra/security/signed_cookies.py +80 -0
  189. svc_infra/webhooks/__init__.py +16 -0
  190. svc_infra/webhooks/add.py +322 -0
  191. svc_infra/webhooks/fastapi.py +37 -0
  192. svc_infra/webhooks/router.py +55 -0
  193. svc_infra/webhooks/service.py +67 -0
  194. svc_infra/webhooks/signing.py +30 -0
  195. svc_infra-0.1.654.dist-info/METADATA +154 -0
  196. svc_infra-0.1.654.dist-info/RECORD +352 -0
  197. svc_infra/api/fastapi/deps.py +0 -3
  198. svc_infra-0.1.506.dist-info/METADATA +0 -78
  199. svc_infra-0.1.506.dist-info/RECORD +0 -213
  200. /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
  201. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  202. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,1082 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from typing import Literal, Optional, cast
5
+
6
+ from fastapi import Body, Depends, Header, HTTPException, Request, Response, status
7
+ from starlette.responses import JSONResponse
8
+
9
+ from svc_infra.apf_payments.schemas import (
10
+ BalanceSnapshotOut,
11
+ CaptureIn,
12
+ CustomerOut,
13
+ CustomersListFilter,
14
+ CustomerUpsertIn,
15
+ DisputeOut,
16
+ IntentCreateIn,
17
+ IntentListFilter,
18
+ IntentOut,
19
+ InvoiceCreateIn,
20
+ InvoiceLineItemIn,
21
+ InvoiceLineItemOut,
22
+ InvoiceOut,
23
+ InvoicesListFilter,
24
+ PaymentMethodAttachIn,
25
+ PaymentMethodOut,
26
+ PaymentMethodUpdateIn,
27
+ PayoutOut,
28
+ PriceCreateIn,
29
+ PriceOut,
30
+ PriceUpdateIn,
31
+ ProductCreateIn,
32
+ ProductOut,
33
+ ProductUpdateIn,
34
+ RefundIn,
35
+ RefundOut,
36
+ SetupIntentCreateIn,
37
+ SetupIntentOut,
38
+ StatementRow,
39
+ SubscriptionCreateIn,
40
+ SubscriptionOut,
41
+ SubscriptionUpdateIn,
42
+ TransactionRow,
43
+ UsageRecordIn,
44
+ UsageRecordListFilter,
45
+ UsageRecordOut,
46
+ WebhookAckOut,
47
+ WebhookReplayIn,
48
+ WebhookReplayOut,
49
+ )
50
+ from svc_infra.apf_payments.service import PaymentsService
51
+ from svc_infra.api.fastapi.auth.security import OptionalIdentity, Principal
52
+ from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
53
+ from svc_infra.api.fastapi.dual import protected_router, public_router, service_router, user_router
54
+ from svc_infra.api.fastapi.dual.router import DualAPIRouter
55
+ from svc_infra.api.fastapi.middleware.idempotency import require_idempotency_key
56
+ from svc_infra.api.fastapi.pagination import (
57
+ Paginated,
58
+ cursor_pager,
59
+ cursor_window,
60
+ sort_by,
61
+ use_pagination,
62
+ )
63
+
64
+ _TX_KINDS = {"payment", "refund", "fee", "payout", "capture"}
65
+
66
+
67
+ def _tx_kind(kind: str) -> Literal["payment", "refund", "fee", "payout", "capture"]:
68
+ if kind not in _TX_KINDS:
69
+ raise ValueError(f"Unknown ledger kind: {kind!r}")
70
+ return cast(Literal["payment", "refund", "fee", "payout", "capture"], kind)
71
+
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
+
122
+ # --- deps ---
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)
147
+
148
+
149
+ # --- routers grouped by auth posture (same prefix is fine; FastAPI merges) ---
150
+ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
151
+ routers: list[DualAPIRouter] = []
152
+
153
+ pub = public_router(prefix=prefix)
154
+ user = user_router(prefix=prefix)
155
+ svc = service_router(prefix=prefix)
156
+ prot = protected_router(prefix=prefix)
157
+
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
+ )
166
+ async def upsert_customer(data: CustomerUpsertIn, svc: PaymentsService = Depends(get_service)):
167
+ out = await svc.ensure_customer(data)
168
+ await svc.session.flush()
169
+ return out
170
+
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
+ ):
186
+ out = await svc.create_intent(user_id=None, data=data)
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
+ )
191
+ return out
192
+
193
+ routers.append(user)
194
+
195
+ # ===== Payment Intents (confirm/cancel/refund/list/get/capture/resume) =====
196
+ @prot.post(
197
+ "/intents/{provider_intent_id}/confirm",
198
+ response_model=IntentOut,
199
+ name="payments_confirm_intent",
200
+ dependencies=[Depends(require_idempotency_key)],
201
+ tags=["Payment Intents"],
202
+ )
203
+ async def confirm_intent(provider_intent_id: str, svc: PaymentsService = Depends(get_service)):
204
+ out = await svc.confirm_intent(provider_intent_id)
205
+ await svc.session.flush()
206
+ return out
207
+
208
+ @prot.post(
209
+ "/intents/{provider_intent_id}/cancel",
210
+ response_model=IntentOut,
211
+ name="payments_cancel_intent",
212
+ dependencies=[Depends(require_idempotency_key)],
213
+ tags=["Payment Intents"],
214
+ )
215
+ async def cancel_intent(provider_intent_id: str, svc: PaymentsService = Depends(get_service)):
216
+ out = await svc.cancel_intent(provider_intent_id)
217
+ await svc.session.flush()
218
+ return out
219
+
220
+ @prot.post(
221
+ "/intents/{provider_intent_id}/refund",
222
+ response_model=IntentOut,
223
+ name="payments_refund_intent",
224
+ dependencies=[Depends(require_idempotency_key)],
225
+ tags=["Payment Intents", "Refunds"],
226
+ )
227
+ async def refund_intent(
228
+ provider_intent_id: str, data: RefundIn, svc: PaymentsService = Depends(get_service)
229
+ ):
230
+ out = await svc.refund(provider_intent_id, data)
231
+ await svc.session.flush()
232
+ return out
233
+
234
+ @prot.get(
235
+ "/transactions",
236
+ response_model=Paginated[TransactionRow],
237
+ name="payments_list_transactions",
238
+ dependencies=[Depends(cursor_pager(default_limit=50, max_limit=200))],
239
+ tags=["Transactions"],
240
+ )
241
+ async def list_transactions(svc: PaymentsService = Depends(get_service)):
242
+ from sqlalchemy import select
243
+
244
+ from svc_infra.apf_payments.models import LedgerEntry
245
+
246
+ rows = (await svc.session.execute(select(LedgerEntry))).scalars().all()
247
+ rows_sorted = sort_by(rows, key=lambda e: e.ts, desc=True)
248
+
249
+ ctx = use_pagination()
250
+ window, next_cursor = cursor_window(
251
+ rows_sorted,
252
+ cursor=ctx.cursor,
253
+ limit=ctx.limit,
254
+ key=lambda e: int(e.ts.timestamp()),
255
+ descending=True,
256
+ )
257
+
258
+ items = [
259
+ TransactionRow(
260
+ id=e.id,
261
+ ts=e.ts.isoformat(),
262
+ type=_tx_kind(e.kind),
263
+ amount=int(e.amount),
264
+ currency=e.currency,
265
+ status=e.status,
266
+ provider=e.provider,
267
+ provider_ref=e.provider_ref or "",
268
+ user_id=e.user_id,
269
+ )
270
+ for e in window
271
+ ]
272
+ return ctx.wrap(items, next_cursor=next_cursor)
273
+
274
+ routers.append(prot)
275
+
276
+ @pub.post(
277
+ "/webhooks/{provider}",
278
+ name="payments_webhook",
279
+ response_model=WebhookAckOut,
280
+ tags=["Webhooks"],
281
+ )
282
+ async def webhooks(
283
+ provider: str,
284
+ request: Request,
285
+ svc: PaymentsService = Depends(get_service),
286
+ signature: Optional[str] = Header(None, alias="Stripe-Signature"),
287
+ ):
288
+ payload = await request.body()
289
+ out = await svc.handle_webhook(provider.lower(), signature, payload)
290
+ await svc.session.flush()
291
+ return JSONResponse(out)
292
+
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
+ )
302
+ async def attach_method(
303
+ data: PaymentMethodAttachIn, svc: PaymentsService = Depends(get_service)
304
+ ):
305
+ out = await svc.attach_payment_method(data)
306
+ await svc.session.flush()
307
+ return out
308
+
309
+ @prot.get(
310
+ "/methods",
311
+ response_model=Paginated[PaymentMethodOut],
312
+ name="payments_list_methods",
313
+ dependencies=[Depends(cursor_pager(default_limit=50, max_limit=200))],
314
+ tags=["Payment Methods"],
315
+ )
316
+ async def list_methods(
317
+ customer_provider_id: str,
318
+ svc: PaymentsService = Depends(get_service),
319
+ ):
320
+ methods = await svc.list_payment_methods(customer_provider_id)
321
+ methods_sorted = sort_by(
322
+ sort_by(methods, key=lambda m: m.provider_method_id or "", desc=False),
323
+ key=lambda m: m.is_default,
324
+ desc=True,
325
+ )
326
+ ctx = use_pagination()
327
+ window, next_cursor = cursor_window(
328
+ methods_sorted,
329
+ cursor=ctx.cursor,
330
+ limit=ctx.limit,
331
+ key=lambda m: m.provider_method_id or "",
332
+ descending=False,
333
+ )
334
+ return ctx.wrap(window, next_cursor=next_cursor)
335
+
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
+ )
343
+ async def detach_method(provider_method_id: str, svc: PaymentsService = Depends(get_service)):
344
+ out = await svc.detach_payment_method(provider_method_id)
345
+ await svc.session.flush()
346
+ return out
347
+
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
+ )
355
+ async def set_default_method(
356
+ provider_method_id: str,
357
+ customer_provider_id: str,
358
+ svc: PaymentsService = Depends(get_service),
359
+ ):
360
+ out = await svc.set_default_payment_method(customer_provider_id, provider_method_id)
361
+ await svc.session.flush()
362
+ return out
363
+
364
+ # PRODUCTS/PRICES
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
+ )
373
+ async def create_product(data: ProductCreateIn, svc: PaymentsService = Depends(get_service)):
374
+ out = await svc.create_product(data)
375
+ await svc.session.flush()
376
+ return out
377
+
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
+ )
386
+ async def create_price(data: PriceCreateIn, svc: PaymentsService = Depends(get_service)):
387
+ out = await svc.create_price(data)
388
+ await svc.session.flush()
389
+ return out
390
+
391
+ # SUBSCRIPTIONS
392
+ @prot.post(
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"],
399
+ )
400
+ async def create_subscription(
401
+ data: SubscriptionCreateIn, svc: PaymentsService = Depends(get_service)
402
+ ):
403
+ out = await svc.create_subscription(data)
404
+ await svc.session.flush()
405
+ return out
406
+
407
+ @prot.post(
408
+ "/subscriptions/{provider_subscription_id}",
409
+ response_model=SubscriptionOut,
410
+ name="payments_update_subscription",
411
+ dependencies=[Depends(require_idempotency_key)],
412
+ tags=["Subscriptions"],
413
+ )
414
+ async def update_subscription(
415
+ provider_subscription_id: str,
416
+ data: SubscriptionUpdateIn,
417
+ svc: PaymentsService = Depends(get_service),
418
+ ):
419
+ out = await svc.update_subscription(provider_subscription_id, data)
420
+ await svc.session.flush()
421
+ return out
422
+
423
+ @prot.post(
424
+ "/subscriptions/{provider_subscription_id}/cancel",
425
+ response_model=SubscriptionOut,
426
+ name="payments_cancel_subscription",
427
+ dependencies=[Depends(require_idempotency_key)],
428
+ tags=["Subscriptions"],
429
+ )
430
+ async def cancel_subscription(
431
+ provider_subscription_id: str,
432
+ at_period_end: bool = True,
433
+ svc: PaymentsService = Depends(get_service),
434
+ ):
435
+ out = await svc.cancel_subscription(provider_subscription_id, at_period_end)
436
+ await svc.session.flush()
437
+ return out
438
+
439
+ # INVOICES
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
+ ):
454
+ out = await svc.create_invoice(data)
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
+ )
459
+ return out
460
+
461
+ @prot.post(
462
+ "/invoices/{provider_invoice_id}/finalize",
463
+ response_model=InvoiceOut,
464
+ name="payments_finalize_invoice",
465
+ dependencies=[Depends(require_idempotency_key)],
466
+ tags=["Invoices"],
467
+ )
468
+ async def finalize_invoice(
469
+ provider_invoice_id: str, svc: PaymentsService = Depends(get_service)
470
+ ):
471
+ out = await svc.finalize_invoice(provider_invoice_id)
472
+ await svc.session.flush()
473
+ return out
474
+
475
+ @prot.post(
476
+ "/invoices/{provider_invoice_id}/void",
477
+ response_model=InvoiceOut,
478
+ name="payments_void_invoice",
479
+ dependencies=[Depends(require_idempotency_key)],
480
+ tags=["Invoices"],
481
+ )
482
+ async def void_invoice(provider_invoice_id: str, svc: PaymentsService = Depends(get_service)):
483
+ out = await svc.void_invoice(provider_invoice_id)
484
+ await svc.session.flush()
485
+ return out
486
+
487
+ @prot.post(
488
+ "/invoices/{provider_invoice_id}/pay",
489
+ response_model=InvoiceOut,
490
+ name="payments_pay_invoice",
491
+ dependencies=[Depends(require_idempotency_key)],
492
+ tags=["Invoices"],
493
+ )
494
+ async def pay_invoice(provider_invoice_id: str, svc: PaymentsService = Depends(get_service)):
495
+ out = await svc.pay_invoice(provider_invoice_id)
496
+ await svc.session.flush()
497
+ return out
498
+
499
+ # INTENTS: get/hydrate
500
+ @prot.get(
501
+ "/intents/{provider_intent_id}",
502
+ response_model=IntentOut,
503
+ name="payments_get_intent",
504
+ tags=["Payment Intents"],
505
+ )
506
+ async def get_intent(provider_intent_id: str, svc: PaymentsService = Depends(get_service)):
507
+ return await svc.get_intent(provider_intent_id)
508
+
509
+ # STATEMENTS (rollup)
510
+ @prot.get(
511
+ "/statements/daily",
512
+ response_model=list[StatementRow],
513
+ name="payments_daily_statements",
514
+ tags=["Statements"],
515
+ )
516
+ async def daily_statements(
517
+ date_from: str | None = None,
518
+ date_to: str | None = None,
519
+ svc: PaymentsService = Depends(get_service),
520
+ ):
521
+ return await svc.daily_statements_rollup(date_from, date_to)
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
+
1080
+ routers.append(svc)
1081
+ routers.append(pub)
1082
+ return routers