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,873 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import partial
4
+ from typing import Any, Optional
5
+
6
+ import anyio
7
+
8
+ from ..schemas import (
9
+ BalanceSnapshotOut,
10
+ CustomerOut,
11
+ CustomerUpsertIn,
12
+ DisputeOut,
13
+ IntentCreateIn,
14
+ IntentOut,
15
+ InvoiceCreateIn,
16
+ InvoiceLineItemIn,
17
+ InvoiceLineItemOut,
18
+ InvoiceOut,
19
+ NextAction,
20
+ PaymentMethodAttachIn,
21
+ PaymentMethodOut,
22
+ PaymentMethodUpdateIn,
23
+ PayoutOut,
24
+ PriceCreateIn,
25
+ PriceOut,
26
+ PriceUpdateIn,
27
+ ProductCreateIn,
28
+ ProductOut,
29
+ ProductUpdateIn,
30
+ RefundIn,
31
+ RefundOut,
32
+ SetupIntentCreateIn,
33
+ SetupIntentOut,
34
+ SubscriptionCreateIn,
35
+ SubscriptionOut,
36
+ SubscriptionUpdateIn,
37
+ UsageRecordIn,
38
+ UsageRecordListFilter,
39
+ UsageRecordOut,
40
+ )
41
+ from ..settings import get_payments_settings
42
+ from .base import ProviderAdapter
43
+
44
+ try:
45
+ import stripe
46
+ except Exception: # pragma: no cover
47
+ stripe = None # type: ignore
48
+
49
+
50
+ async def _acall(fn, /, *args, **kwargs):
51
+ return await anyio.to_thread.run_sync(partial(fn, *args, **kwargs))
52
+
53
+
54
+ def _pi_to_out(pi) -> IntentOut:
55
+ return IntentOut(
56
+ id=pi.id,
57
+ provider="stripe",
58
+ provider_intent_id=pi.id,
59
+ status=pi.status,
60
+ amount=int(pi.amount),
61
+ currency=str(pi.currency).upper(),
62
+ client_secret=getattr(pi, "client_secret", None),
63
+ next_action=NextAction(type=getattr(getattr(pi, "next_action", None), "type", None)),
64
+ )
65
+
66
+
67
+ def _inv_to_out(inv) -> InvoiceOut:
68
+ return InvoiceOut(
69
+ id=inv.id,
70
+ provider="stripe",
71
+ provider_invoice_id=inv.id,
72
+ provider_customer_id=inv.customer,
73
+ status=inv.status,
74
+ amount_due=int(inv.amount_due or 0),
75
+ currency=str(inv.currency).upper(),
76
+ hosted_invoice_url=getattr(inv, "hosted_invoice_url", None),
77
+ pdf_url=getattr(inv, "invoice_pdf", None),
78
+ )
79
+
80
+
81
+ def _pm_to_out(pm, *, is_default: bool = False) -> PaymentMethodOut:
82
+ card = getattr(pm, "card", None) or {}
83
+ return PaymentMethodOut(
84
+ id=pm.id,
85
+ provider="stripe",
86
+ provider_customer_id=getattr(pm, "customer", None) or "",
87
+ provider_method_id=pm.id,
88
+ brand=card.get("brand"),
89
+ last4=card.get("last4"),
90
+ exp_month=card.get("exp_month"),
91
+ exp_year=card.get("exp_year"),
92
+ is_default=bool(is_default),
93
+ )
94
+
95
+
96
+ def _product_to_out(p) -> ProductOut:
97
+ return ProductOut(
98
+ id=p.id,
99
+ provider="stripe",
100
+ provider_product_id=p.id,
101
+ name=p.name,
102
+ active=bool(p.active),
103
+ )
104
+
105
+
106
+ def _price_to_out(pr) -> PriceOut:
107
+ rec = getattr(pr, "recurring", None) or {}
108
+ return PriceOut(
109
+ id=pr.id,
110
+ provider="stripe",
111
+ provider_price_id=pr.id,
112
+ provider_product_id=(
113
+ pr.product if isinstance(pr.product, str) else getattr(pr.product, "id", "")
114
+ ),
115
+ currency=str(pr.currency).upper(),
116
+ unit_amount=int(pr.unit_amount),
117
+ interval=rec.get("interval"),
118
+ trial_days=getattr(pr, "trial_period_days", None),
119
+ active=bool(pr.active),
120
+ )
121
+
122
+
123
+ def _sub_to_out(s) -> SubscriptionOut:
124
+ # pick first item’s price/quantity for simple one-item subs
125
+ item = s.items.data[0] if getattr(s.items, "data", []) else None
126
+ price_id = item.price.id if item and getattr(item, "price", None) else ""
127
+ qty = item.quantity if item else 0
128
+ return SubscriptionOut(
129
+ id=s.id,
130
+ provider="stripe",
131
+ provider_subscription_id=s.id,
132
+ provider_price_id=price_id,
133
+ status=s.status,
134
+ quantity=int(qty or 0),
135
+ cancel_at_period_end=bool(s.cancel_at_period_end),
136
+ current_period_end=(
137
+ str(s.current_period_end) if getattr(s, "current_period_end", None) else None
138
+ ),
139
+ )
140
+
141
+
142
+ def _refund_to_out(r) -> RefundOut:
143
+ return RefundOut(
144
+ id=r.id,
145
+ provider="stripe",
146
+ provider_refund_id=r.id,
147
+ provider_payment_intent_id=getattr(r, "payment_intent", None),
148
+ amount=int(r.amount),
149
+ currency=str(r.currency).upper(),
150
+ status=r.status,
151
+ reason=getattr(r, "reason", None),
152
+ created_at=str(r.created) if getattr(r, "created", None) else None,
153
+ )
154
+
155
+
156
+ def _dispute_to_out(d) -> DisputeOut:
157
+ return DisputeOut(
158
+ id=d.id,
159
+ provider="stripe",
160
+ provider_dispute_id=d.id,
161
+ amount=int(d.amount),
162
+ currency=str(d.currency).upper(),
163
+ reason=getattr(d, "reason", None),
164
+ status=d.status,
165
+ evidence_due_by=(
166
+ str(d.evidence_details.get("due_by")) if getattr(d, "evidence_details", None) else None
167
+ ),
168
+ created_at=str(d.created) if getattr(d, "created", None) else None,
169
+ )
170
+
171
+
172
+ def _payout_to_out(p) -> PayoutOut:
173
+ return PayoutOut(
174
+ id=p.id,
175
+ provider="stripe",
176
+ provider_payout_id=p.id,
177
+ amount=int(p.amount),
178
+ currency=str(p.currency).upper(),
179
+ status=p.status,
180
+ arrival_date=str(p.arrival_date) if getattr(p, "arrival_date", None) else None,
181
+ type=getattr(p, "type", None),
182
+ )
183
+
184
+
185
+ class StripeAdapter(ProviderAdapter):
186
+ name = "stripe"
187
+
188
+ def __init__(self):
189
+ st = get_payments_settings()
190
+ if not st.stripe or not st.stripe.secret_key.get_secret_value():
191
+ raise RuntimeError("Stripe settings not configured")
192
+ if stripe is None:
193
+ raise RuntimeError("stripe SDK is not installed. pip install stripe")
194
+ stripe.api_key = st.stripe.secret_key.get_secret_value()
195
+ self._wh_secret = (
196
+ st.stripe.webhook_secret.get_secret_value() if st.stripe.webhook_secret else None
197
+ )
198
+
199
+ # -------- Customers --------
200
+ async def ensure_customer(self, data: CustomerUpsertIn) -> CustomerOut:
201
+ if data.email:
202
+ existing = await _acall(stripe.Customer.list, email=data.email, limit=1)
203
+ c = (
204
+ existing.data[0]
205
+ if existing.data
206
+ else await _acall(
207
+ stripe.Customer.create,
208
+ email=data.email,
209
+ name=data.name or None,
210
+ metadata={"user_id": data.user_id or ""},
211
+ )
212
+ )
213
+ else:
214
+ c = await _acall(
215
+ stripe.Customer.create,
216
+ name=data.name or None,
217
+ metadata={"user_id": data.user_id or ""},
218
+ )
219
+ return CustomerOut(
220
+ id=c.id,
221
+ provider="stripe",
222
+ provider_customer_id=c.id,
223
+ email=c.get("email"),
224
+ name=c.get("name"),
225
+ )
226
+
227
+ async def get_customer(self, provider_customer_id: str) -> Optional[CustomerOut]:
228
+ c = await _acall(stripe.Customer.retrieve, provider_customer_id)
229
+ return CustomerOut(
230
+ id=c.id,
231
+ provider="stripe",
232
+ provider_customer_id=c.id,
233
+ email=c.get("email"),
234
+ name=c.get("name"),
235
+ )
236
+
237
+ async def list_customers(
238
+ self, *, provider: str | None, user_id: str | None, limit: int, cursor: str | None
239
+ ) -> tuple[list[CustomerOut], str | None]:
240
+ params = {"limit": int(limit)}
241
+ if cursor:
242
+ params["starting_after"] = cursor
243
+ # Stripe has no direct filter for our custom user_id; many teams store mapping in DB.
244
+ # If 'user_id' was stored in metadata, we could search via /v1/customers?limit=... then filter client-side.
245
+ res = await _acall(stripe.Customer.list, **params)
246
+ items = [
247
+ CustomerOut(
248
+ id=c.id,
249
+ provider="stripe",
250
+ provider_customer_id=c.id,
251
+ email=c.get("email"),
252
+ name=c.get("name"),
253
+ )
254
+ for c in res.data
255
+ ]
256
+ next_cursor = res.data[-1].id if getattr(res, "has_more", False) and res.data else None
257
+ return items, next_cursor
258
+
259
+ # -------- Payment Methods --------
260
+ async def attach_payment_method(self, data: PaymentMethodAttachIn) -> PaymentMethodOut:
261
+ pm = await _acall(
262
+ stripe.PaymentMethod.attach,
263
+ data.payment_method_token,
264
+ customer=data.customer_provider_id,
265
+ )
266
+ is_default = False
267
+ if data.make_default:
268
+ cust = await _acall(
269
+ stripe.Customer.modify,
270
+ data.customer_provider_id,
271
+ invoice_settings={"default_payment_method": pm.id},
272
+ )
273
+ is_default = (
274
+ getattr(getattr(cust, "invoice_settings", None), "default_payment_method", None)
275
+ == pm.id
276
+ )
277
+ else:
278
+ cust = await _acall(stripe.Customer.retrieve, data.customer_provider_id)
279
+ is_default = (
280
+ getattr(getattr(cust, "invoice_settings", None), "default_payment_method", None)
281
+ == pm.id
282
+ )
283
+ return _pm_to_out(pm, is_default=is_default)
284
+
285
+ async def list_payment_methods(self, provider_customer_id: str) -> list[PaymentMethodOut]:
286
+ cust = await _acall(stripe.Customer.retrieve, provider_customer_id)
287
+ default_pm = getattr(
288
+ getattr(cust, "invoice_settings", None), "default_payment_method", None
289
+ )
290
+ res = await _acall(stripe.PaymentMethod.list, customer=provider_customer_id, type="card")
291
+ return [_pm_to_out(pm, is_default=(pm.id == default_pm)) for pm in res.data]
292
+
293
+ async def detach_payment_method(self, provider_method_id: str) -> PaymentMethodOut:
294
+ pm = await _acall(stripe.PaymentMethod.detach, provider_method_id)
295
+ # we no longer know default status reliably—fetch customer if set
296
+ cust_id = getattr(pm, "customer", None)
297
+ default_pm = None
298
+ if cust_id:
299
+ cust = await _acall(stripe.Customer.retrieve, cust_id)
300
+ default_pm = getattr(
301
+ getattr(cust, "invoice_settings", None), "default_payment_method", None
302
+ )
303
+ return _pm_to_out(pm, is_default=(pm.id == default_pm))
304
+
305
+ async def set_default_payment_method(
306
+ self, provider_customer_id: str, provider_method_id: str
307
+ ) -> PaymentMethodOut:
308
+ cust = await _acall(
309
+ stripe.Customer.modify,
310
+ provider_customer_id,
311
+ invoice_settings={"default_payment_method": provider_method_id},
312
+ )
313
+ pm = await _acall(stripe.PaymentMethod.retrieve, provider_method_id)
314
+ is_default = (
315
+ getattr(getattr(cust, "invoice_settings", None), "default_payment_method", None)
316
+ == pm.id
317
+ )
318
+ return _pm_to_out(pm, is_default=is_default)
319
+
320
+ async def get_payment_method(self, provider_method_id: str) -> PaymentMethodOut:
321
+ pm = await _acall(stripe.PaymentMethod.retrieve, provider_method_id)
322
+ cust_id = getattr(pm, "customer", None)
323
+ default_pm = None
324
+ if cust_id:
325
+ cust = await _acall(stripe.Customer.retrieve, cust_id)
326
+ default_pm = getattr(
327
+ getattr(cust, "invoice_settings", None), "default_payment_method", None
328
+ )
329
+ return _pm_to_out(pm, is_default=(pm.id == default_pm))
330
+
331
+ async def update_payment_method(
332
+ self, provider_method_id: str, data: PaymentMethodUpdateIn
333
+ ) -> PaymentMethodOut:
334
+ update: dict[str, Any] = {}
335
+ if data.name is not None:
336
+ update["billing_details"] = {"name": data.name}
337
+ if data.exp_month is not None or data.exp_year is not None:
338
+ update["card"] = {}
339
+ if data.exp_month is not None:
340
+ update["card"]["exp_month"] = data.exp_month
341
+ if data.exp_year is not None:
342
+ update["card"]["exp_year"] = data.exp_year
343
+ pm = (
344
+ await _acall(stripe.PaymentMethod.modify, provider_method_id, **update)
345
+ if update
346
+ else await _acall(stripe.PaymentMethod.retrieve, provider_method_id)
347
+ )
348
+ cust_id = getattr(pm, "customer", None)
349
+ default_pm = None
350
+ if cust_id:
351
+ cust = await _acall(stripe.Customer.retrieve, cust_id)
352
+ default_pm = getattr(
353
+ getattr(cust, "invoice_settings", None), "default_payment_method", None
354
+ )
355
+ return _pm_to_out(pm, is_default=(pm.id == default_pm))
356
+
357
+ # -------- Products / Prices --------
358
+ async def create_product(self, data: ProductCreateIn) -> ProductOut:
359
+ p = await _acall(stripe.Product.create, name=data.name, active=bool(data.active))
360
+ return _product_to_out(p)
361
+
362
+ async def get_product(self, provider_product_id: str) -> ProductOut:
363
+ p = await _acall(stripe.Product.retrieve, provider_product_id)
364
+ return _product_to_out(p)
365
+
366
+ async def list_products(
367
+ self, *, active: bool | None, limit: int, cursor: str | None
368
+ ) -> tuple[list[ProductOut], str | None]:
369
+ params = {"limit": int(limit)}
370
+ if active is not None:
371
+ params["active"] = bool(active)
372
+ if cursor:
373
+ params["starting_after"] = cursor
374
+ res = await _acall(stripe.Product.list, **params)
375
+ items = [_product_to_out(p) for p in res.data]
376
+ next_cursor = res.data[-1].id if getattr(res, "has_more", False) and res.data else None
377
+ return items, next_cursor
378
+
379
+ async def update_product(self, provider_product_id: str, data: ProductUpdateIn) -> ProductOut:
380
+ update: dict[str, Any] = {}
381
+ if data.name is not None:
382
+ update["name"] = data.name
383
+ if data.active is not None:
384
+ update["active"] = bool(data.active)
385
+ p = (
386
+ await _acall(stripe.Product.modify, provider_product_id, **update)
387
+ if update
388
+ else await _acall(stripe.Product.retrieve, provider_product_id)
389
+ )
390
+ return _product_to_out(p)
391
+
392
+ async def create_price(self, data: PriceCreateIn) -> PriceOut:
393
+ kwargs: dict[str, Any] = dict(
394
+ product=data.provider_product_id,
395
+ currency=data.currency.lower(),
396
+ unit_amount=int(data.unit_amount),
397
+ active=bool(data.active),
398
+ )
399
+ if data.interval:
400
+ kwargs["recurring"] = {"interval": data.interval}
401
+ if data.trial_days is not None:
402
+ kwargs["trial_period_days"] = int(data.trial_days)
403
+ pr = await _acall(stripe.Price.create, **kwargs)
404
+ return _price_to_out(pr)
405
+
406
+ async def get_price(self, provider_price_id: str) -> PriceOut:
407
+ pr = await _acall(stripe.Price.retrieve, provider_price_id)
408
+ return _price_to_out(pr)
409
+
410
+ async def list_prices(
411
+ self,
412
+ *,
413
+ provider_product_id: str | None,
414
+ active: bool | None,
415
+ limit: int,
416
+ cursor: str | None,
417
+ ) -> tuple[list[PriceOut], str | None]:
418
+ params = {"limit": int(limit)}
419
+ if provider_product_id:
420
+ params["product"] = provider_product_id
421
+ if active is not None:
422
+ params["active"] = bool(active)
423
+ if cursor:
424
+ params["starting_after"] = cursor
425
+ res = await _acall(stripe.Price.list, **params)
426
+ items = [_price_to_out(p) for p in res.data]
427
+ next_cursor = res.data[-1].id if getattr(res, "has_more", False) and res.data else None
428
+ return items, next_cursor
429
+
430
+ async def update_price(self, provider_price_id: str, data: PriceUpdateIn) -> PriceOut:
431
+ # Stripe allows toggling `active` and updating metadata, but not amount/currency/product.
432
+ update: dict[str, Any] = {}
433
+ if data.active is not None:
434
+ update["active"] = bool(data.active)
435
+ pr = (
436
+ await _acall(stripe.Price.modify, provider_price_id, **update)
437
+ if update
438
+ else await _acall(stripe.Price.retrieve, provider_price_id)
439
+ )
440
+ return _price_to_out(pr)
441
+
442
+ # -------- Subscriptions --------
443
+ async def create_subscription(self, data: SubscriptionCreateIn) -> SubscriptionOut:
444
+ kwargs: dict[str, Any] = dict(
445
+ customer=data.customer_provider_id,
446
+ items=[{"price": data.price_provider_id, "quantity": int(data.quantity)}],
447
+ proration_behavior=data.proration_behavior,
448
+ )
449
+ if data.trial_days is not None:
450
+ kwargs["trial_period_days"] = int(data.trial_days)
451
+ s = await _acall(stripe.Subscription.create, **kwargs)
452
+ return _sub_to_out(s)
453
+
454
+ async def update_subscription(
455
+ self, provider_subscription_id: str, data: SubscriptionUpdateIn
456
+ ) -> SubscriptionOut:
457
+ s = await _acall(stripe.Subscription.retrieve, provider_subscription_id, expand=["items"])
458
+ items = s.items.data
459
+ update_kwargs: dict[str, Any] = {"proration_behavior": data.proration_behavior}
460
+ # update first item (simple plan model)
461
+ if items:
462
+ first_item = items[0]
463
+ item_update = {"id": first_item.id}
464
+ if data.price_provider_id:
465
+ item_update["price"] = data.price_provider_id
466
+ if data.quantity is not None:
467
+ item_update["quantity"] = int(data.quantity)
468
+ update_kwargs["items"] = [item_update]
469
+ if data.cancel_at_period_end is not None:
470
+ update_kwargs["cancel_at_period_end"] = bool(data.cancel_at_period_end)
471
+ s2 = await _acall(stripe.Subscription.modify, provider_subscription_id, **update_kwargs)
472
+ return _sub_to_out(s2)
473
+
474
+ async def cancel_subscription(
475
+ self, provider_subscription_id: str, at_period_end: bool = True
476
+ ) -> SubscriptionOut:
477
+ s = await _acall(
478
+ stripe.Subscription.cancel if not at_period_end else stripe.Subscription.modify,
479
+ provider_subscription_id,
480
+ **({} if not at_period_end else {"cancel_at_period_end": True}),
481
+ )
482
+ return _sub_to_out(s)
483
+
484
+ async def get_subscription(self, provider_subscription_id: str) -> SubscriptionOut:
485
+ s = await _acall(stripe.Subscription.retrieve, provider_subscription_id, expand=["items"])
486
+ return _sub_to_out(s)
487
+
488
+ async def list_subscriptions(
489
+ self,
490
+ *,
491
+ customer_provider_id: str | None,
492
+ status: str | None,
493
+ limit: int,
494
+ cursor: str | None,
495
+ ) -> tuple[list[SubscriptionOut], str | None]:
496
+ params = {"limit": int(limit)}
497
+ if customer_provider_id:
498
+ params["customer"] = customer_provider_id
499
+ if status:
500
+ params["status"] = status
501
+ if cursor:
502
+ params["starting_after"] = cursor
503
+ res = await _acall(stripe.Subscription.list, **params)
504
+ items = [_sub_to_out(s) for s in res.data]
505
+ next_cursor = res.data[-1].id if getattr(res, "has_more", False) and res.data else None
506
+ return items, next_cursor
507
+
508
+ # -------- Invoices --------
509
+ async def create_invoice(self, data: InvoiceCreateIn) -> InvoiceOut:
510
+ inv = await _acall(
511
+ stripe.Invoice.create,
512
+ customer=data.customer_provider_id,
513
+ auto_advance=bool(data.auto_advance),
514
+ )
515
+ return _inv_to_out(inv)
516
+
517
+ async def finalize_invoice(self, provider_invoice_id: str) -> InvoiceOut:
518
+ inv = await _acall(stripe.Invoice.finalize_invoice, provider_invoice_id)
519
+ return _inv_to_out(inv)
520
+
521
+ async def void_invoice(self, provider_invoice_id: str) -> InvoiceOut:
522
+ inv = await _acall(stripe.Invoice.void_invoice, provider_invoice_id)
523
+ return _inv_to_out(inv)
524
+
525
+ async def pay_invoice(self, provider_invoice_id: str) -> InvoiceOut:
526
+ inv = await _acall(stripe.Invoice.pay, provider_invoice_id)
527
+ return _inv_to_out(inv)
528
+
529
+ async def add_invoice_line_item(
530
+ self, provider_invoice_id: str, data: InvoiceLineItemIn
531
+ ) -> InvoiceOut:
532
+ # attach an item to a DRAFT invoice
533
+ kwargs: dict[str, Any] = dict(
534
+ invoice=provider_invoice_id,
535
+ customer=data.customer_provider_id,
536
+ quantity=int(data.quantity or 1),
537
+ currency=data.currency.lower(),
538
+ description=data.description or None,
539
+ )
540
+ if data.provider_price_id:
541
+ kwargs["price"] = data.provider_price_id
542
+ else:
543
+ kwargs["unit_amount"] = int(data.unit_amount)
544
+ await _acall(
545
+ stripe.InvoiceItem.create, **{k: v for k, v in kwargs.items() if v is not None}
546
+ )
547
+ inv = await _acall(stripe.Invoice.retrieve, provider_invoice_id)
548
+ return _inv_to_out(inv)
549
+
550
+ async def list_invoices(
551
+ self,
552
+ *,
553
+ customer_provider_id: str | None,
554
+ status: str | None,
555
+ limit: int,
556
+ cursor: str | None,
557
+ ) -> tuple[list[InvoiceOut], str | None]:
558
+ params = {"limit": int(limit)}
559
+ if customer_provider_id:
560
+ params["customer"] = customer_provider_id
561
+ if status:
562
+ params["status"] = status
563
+ if cursor:
564
+ params["starting_after"] = cursor
565
+ res = await _acall(stripe.Invoice.list, **params)
566
+ items = [_inv_to_out(inv) for inv in res.data]
567
+ next_cursor = res.data[-1].id if getattr(res, "has_more", False) and res.data else None
568
+ return items, next_cursor
569
+
570
+ async def get_invoice(self, provider_invoice_id: str) -> InvoiceOut:
571
+ inv = await _acall(stripe.Invoice.retrieve, provider_invoice_id)
572
+ return _inv_to_out(inv)
573
+
574
+ async def preview_invoice(
575
+ self, *, customer_provider_id: str, subscription_id: str | None = None
576
+ ) -> InvoiceOut:
577
+ params = {"customer": customer_provider_id}
578
+ if subscription_id:
579
+ params["subscription"] = subscription_id
580
+ inv = await _acall(stripe.Invoice.upcoming, **params)
581
+ return _inv_to_out(inv)
582
+
583
+ async def list_invoice_line_items(
584
+ self, provider_invoice_id: str, *, limit: int, cursor: str | None
585
+ ) -> tuple[list[InvoiceLineItemOut], str | None]:
586
+ params = {"limit": int(limit)}
587
+ if cursor:
588
+ params["starting_after"] = cursor
589
+ res = await _acall(stripe.Invoice.list_lines, provider_invoice_id, **params)
590
+ items: list[InvoiceLineItemOut] = []
591
+ for li in res.data:
592
+ amount = int(getattr(li, "amount", 0))
593
+ currency = str(getattr(li, "currency", "USD")).upper()
594
+ price_id = getattr(getattr(li, "price", None), "id", None)
595
+ items.append(
596
+ InvoiceLineItemOut(
597
+ id=li.id,
598
+ description=getattr(li, "description", None),
599
+ amount=amount,
600
+ currency=currency,
601
+ quantity=getattr(li, "quantity", 1),
602
+ provider_price_id=price_id,
603
+ )
604
+ )
605
+ next_cursor = res.data[-1].id if getattr(res, "has_more", False) and res.data else None
606
+ return items, next_cursor
607
+
608
+ # -------- Intents --------
609
+ async def create_intent(self, data: IntentCreateIn, *, user_id: str | None) -> IntentOut:
610
+ kwargs: dict[str, Any] = dict(
611
+ amount=int(data.amount),
612
+ currency=data.currency.lower(),
613
+ description=data.description or None,
614
+ capture_method="manual" if data.capture_method == "manual" else "automatic",
615
+ automatic_payment_methods={"enabled": True} if not data.payment_method_types else None,
616
+ )
617
+ if data.payment_method_types:
618
+ kwargs["payment_method_types"] = data.payment_method_types
619
+ pi = await _acall(
620
+ stripe.PaymentIntent.create, **{k: v for k, v in kwargs.items() if v is not None}
621
+ )
622
+ return _pi_to_out(pi)
623
+
624
+ async def confirm_intent(self, provider_intent_id: str) -> IntentOut:
625
+ pi = await _acall(stripe.PaymentIntent.confirm, provider_intent_id)
626
+ return _pi_to_out(pi)
627
+
628
+ async def cancel_intent(self, provider_intent_id: str) -> IntentOut:
629
+ pi = await _acall(stripe.PaymentIntent.cancel, provider_intent_id)
630
+ return _pi_to_out(pi)
631
+
632
+ async def refund(self, provider_intent_id: str, data: RefundIn) -> IntentOut:
633
+ pi = await _acall(
634
+ stripe.PaymentIntent.retrieve, provider_intent_id, expand=["latest_charge"]
635
+ )
636
+ charge_id = pi.latest_charge.id if getattr(pi, "latest_charge", None) else None
637
+ if not charge_id:
638
+ raise ValueError("No charge available to refund")
639
+ await _acall(
640
+ stripe.Refund.create,
641
+ charge=charge_id,
642
+ amount=int(data.amount) if data.amount else None,
643
+ reason=data.reason or None,
644
+ )
645
+ return await self.hydrate_intent(provider_intent_id)
646
+
647
+ async def hydrate_intent(self, provider_intent_id: str) -> IntentOut:
648
+ pi = await _acall(stripe.PaymentIntent.retrieve, provider_intent_id)
649
+ return _pi_to_out(pi)
650
+
651
+ async def capture_intent(self, provider_intent_id: str, *, amount: int | None) -> IntentOut:
652
+ kwargs = {}
653
+ if amount is not None:
654
+ kwargs["amount_to_capture"] = int(amount)
655
+ pi = await _acall(stripe.PaymentIntent.capture, provider_intent_id, **kwargs)
656
+ return _pi_to_out(pi)
657
+
658
+ async def list_intents(
659
+ self,
660
+ *,
661
+ customer_provider_id: str | None,
662
+ status: str | None,
663
+ limit: int,
664
+ cursor: str | None,
665
+ ) -> tuple[list[IntentOut], str | None]:
666
+ params = {"limit": int(limit)}
667
+ if customer_provider_id:
668
+ params["customer"] = customer_provider_id
669
+ if status:
670
+ params["status"] = status
671
+ if cursor:
672
+ params["starting_after"] = cursor
673
+ res = await _acall(stripe.PaymentIntent.list, **params)
674
+ items = [_pi_to_out(pi) for pi in res.data]
675
+ next_cursor = res.data[-1].id if getattr(res, "has_more", False) and res.data else None
676
+ return items, next_cursor
677
+
678
+ # ---- Setup Intents (off-session readiness) ----
679
+ async def create_setup_intent(self, data: SetupIntentCreateIn) -> SetupIntentOut:
680
+ si = await _acall(
681
+ stripe.SetupIntent.create, payment_method_types=data.payment_method_types or ["card"]
682
+ )
683
+ return SetupIntentOut(
684
+ id=si.id,
685
+ provider="stripe",
686
+ provider_setup_intent_id=si.id,
687
+ status=si.status,
688
+ client_secret=getattr(si, "client_secret", None),
689
+ next_action=NextAction(type=getattr(getattr(si, "next_action", None), "type", None)),
690
+ )
691
+
692
+ async def confirm_setup_intent(self, provider_setup_intent_id: str) -> SetupIntentOut:
693
+ si = await _acall(stripe.SetupIntent.confirm, provider_setup_intent_id)
694
+ return SetupIntentOut(
695
+ id=si.id,
696
+ provider="stripe",
697
+ provider_setup_intent_id=si.id,
698
+ status=si.status,
699
+ client_secret=getattr(si, "client_secret", None),
700
+ next_action=NextAction(type=getattr(getattr(si, "next_action", None), "type", None)),
701
+ )
702
+
703
+ async def get_setup_intent(self, provider_setup_intent_id: str) -> SetupIntentOut:
704
+ si = await _acall(stripe.SetupIntent.retrieve, provider_setup_intent_id)
705
+ return SetupIntentOut(
706
+ id=si.id,
707
+ provider="stripe",
708
+ provider_setup_intent_id=si.id,
709
+ status=si.status,
710
+ client_secret=getattr(si, "client_secret", None),
711
+ next_action=NextAction(type=getattr(getattr(si, "next_action", None), "type", None)),
712
+ )
713
+
714
+ # ---- 3DS/SCA resume ----
715
+ async def resume_intent_after_action(self, provider_intent_id: str) -> IntentOut:
716
+ # For Stripe, retrieving after the customer completes next_action is sufficient
717
+ return await self.hydrate_intent(provider_intent_id)
718
+
719
+ # -------- Disputes --------
720
+ async def list_disputes(
721
+ self, *, status: str | None, limit: int, cursor: str | None
722
+ ) -> tuple[list[DisputeOut], str | None]:
723
+ params = {"limit": int(limit)}
724
+ if status:
725
+ params["status"] = status
726
+ if cursor:
727
+ params["starting_after"] = cursor
728
+ res = await _acall(stripe.Dispute.list, **params)
729
+ items = [_dispute_to_out(d) for d in res.data]
730
+ next_cursor = res.data[-1].id if getattr(res, "has_more", False) and res.data else None
731
+ return items, next_cursor
732
+
733
+ async def get_dispute(self, provider_dispute_id: str) -> DisputeOut:
734
+ d = await _acall(stripe.Dispute.retrieve, provider_dispute_id)
735
+ return _dispute_to_out(d)
736
+
737
+ async def submit_dispute_evidence(self, provider_dispute_id: str, evidence: dict) -> DisputeOut:
738
+ d = await _acall(stripe.Dispute.modify, provider_dispute_id, evidence=evidence)
739
+ # Some disputes require explicit submit call:
740
+ try:
741
+ d = await _acall(stripe.Dispute.submit, provider_dispute_id)
742
+ except Exception:
743
+ pass
744
+ return _dispute_to_out(d)
745
+
746
+ # -------- Balance & Payouts --------
747
+ async def get_balance_snapshot(self) -> BalanceSnapshotOut:
748
+ bal = await _acall(stripe.Balance.retrieve)
749
+
750
+ def _bucket(entries):
751
+ out = []
752
+ for b in entries or []:
753
+ out.append({"currency": str(b.currency).upper(), "amount": int(b.amount)})
754
+ return out
755
+
756
+ return BalanceSnapshotOut(
757
+ available=_bucket(getattr(bal, "available", [])),
758
+ pending=_bucket(getattr(bal, "pending", [])),
759
+ )
760
+
761
+ async def list_payouts(
762
+ self, *, limit: int, cursor: str | None
763
+ ) -> tuple[list[PayoutOut], str | None]:
764
+ params = {"limit": int(limit)}
765
+ if cursor:
766
+ params["starting_after"] = cursor
767
+ res = await _acall(stripe.Payout.list, **params)
768
+ items = [_payout_to_out(p) for p in res.data]
769
+ next_cursor = res.data[-1].id if getattr(res, "has_more", False) and res.data else None
770
+ return items, next_cursor
771
+
772
+ async def get_payout(self, provider_payout_id: str) -> PayoutOut:
773
+ p = await _acall(stripe.Payout.retrieve, provider_payout_id)
774
+ return _payout_to_out(p)
775
+
776
+ # -------- Refunds (list/get) --------
777
+ async def list_refunds(
778
+ self, *, provider_payment_intent_id: str | None, limit: int, cursor: str | None
779
+ ) -> tuple[list[RefundOut], str | None]:
780
+ params = {"limit": int(limit)}
781
+ if provider_payment_intent_id:
782
+ params["payment_intent"] = provider_payment_intent_id
783
+ if cursor:
784
+ params["starting_after"] = cursor
785
+ res = await _acall(stripe.Refund.list, **params)
786
+ items = [_refund_to_out(r) for r in res.data]
787
+ next_cursor = res.data[-1].id if getattr(res, "has_more", False) and res.data else None
788
+ return items, next_cursor
789
+
790
+ async def get_refund(self, provider_refund_id: str) -> RefundOut:
791
+ r = await _acall(stripe.Refund.retrieve, provider_refund_id)
792
+ return _refund_to_out(r)
793
+
794
+ # -------- Usage (create/list/get) --------
795
+ async def create_usage_record(self, data: UsageRecordIn) -> UsageRecordOut:
796
+ if not data.subscription_item and not data.provider_price_id:
797
+ raise ValueError("subscription_item or provider_price_id is required")
798
+ sub_item = data.subscription_item
799
+ if not sub_item and data.provider_price_id:
800
+ items = await _acall(
801
+ stripe.SubscriptionItem.list, price=data.provider_price_id, limit=1
802
+ )
803
+ sub_item = items.data[0].id if items.data else None
804
+ if not sub_item:
805
+ raise ValueError("No subscription item found for usage record")
806
+ body = {
807
+ "subscription_item": sub_item,
808
+ "quantity": int(data.quantity),
809
+ "action": data.action or "increment",
810
+ }
811
+ if data.timestamp:
812
+ body["timestamp"] = int(data.timestamp)
813
+ rec = await _acall(stripe.UsageRecord.create, **body)
814
+ return UsageRecordOut(
815
+ id=rec.id,
816
+ quantity=int(rec.quantity),
817
+ timestamp=getattr(rec, "timestamp", None),
818
+ subscription_item=sub_item,
819
+ provider_price_id=data.provider_price_id,
820
+ )
821
+
822
+ async def list_usage_records(
823
+ self, f: UsageRecordListFilter
824
+ ) -> tuple[list[UsageRecordOut], str | None]:
825
+ # Stripe exposes *summaries* per period. We surface them as list results.
826
+ sub_item = f.subscription_item
827
+ if not sub_item and f.provider_price_id:
828
+ items = await _acall(stripe.SubscriptionItem.list, price=f.provider_price_id, limit=1)
829
+ sub_item = items.data[0].id if items.data else None
830
+ if not sub_item:
831
+ return [], None
832
+ params = {"limit": int(f.limit or 50)}
833
+ if f.cursor:
834
+ params["starting_after"] = f.cursor
835
+ res = await _acall(stripe.SubscriptionItem.list_usage_record_summaries, sub_item, **params)
836
+ items: list[UsageRecordOut] = []
837
+ for s in res.data:
838
+ # No record id in summaries—synthesize a stable id from period start.
839
+ synthesized_id = f"{sub_item}:{getattr(s, 'period', {}).get('start')}"
840
+ items.append(
841
+ UsageRecordOut(
842
+ id=synthesized_id,
843
+ quantity=int(getattr(s, "total_usage", 0)),
844
+ timestamp=getattr(s, "period", {}).get("end"),
845
+ subscription_item=sub_item,
846
+ provider_price_id=f.provider_price_id,
847
+ )
848
+ )
849
+ next_cursor = (
850
+ res.data[-1].id
851
+ if getattr(res, "has_more", False) and res.data and hasattr(res.data[-1], "id")
852
+ else None
853
+ )
854
+ return items, next_cursor
855
+
856
+ async def get_usage_record(self, usage_record_id: str) -> UsageRecordOut:
857
+ # Stripe has no direct "retrieve usage record by id" API.
858
+ # You can reconstruct via list summaries or store records locally when creating.
859
+ raise NotImplementedError("Stripe does not support retrieving a single usage record by id")
860
+
861
+ # -------- Webhooks --------
862
+ async def verify_and_parse_webhook(
863
+ self, signature: str | None, payload: bytes
864
+ ) -> dict[str, Any]:
865
+ if not self._wh_secret:
866
+ raise ValueError("Stripe webhook secret not configured")
867
+ event = await _acall(
868
+ stripe.Webhook.construct_event,
869
+ payload=payload,
870
+ sig_header=signature,
871
+ secret=self._wh_secret,
872
+ )
873
+ return {"id": event.id, "type": event.type, "data": event.data.object}