svc-infra 0.1.586__py3-none-any.whl → 0.1.587__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

@@ -6,15 +6,37 @@ from typing import Any, Optional
6
6
  import anyio
7
7
 
8
8
  from ..schemas import (
9
+ BalanceSnapshotOut,
9
10
  CustomerOut,
10
11
  CustomerUpsertIn,
12
+ DisputeOut,
11
13
  IntentCreateIn,
12
14
  IntentOut,
15
+ InvoiceCreateIn,
13
16
  InvoiceLineItemIn,
17
+ InvoiceLineItemOut,
14
18
  InvoiceOut,
15
19
  NextAction,
20
+ PaymentMethodAttachIn,
21
+ PaymentMethodOut,
22
+ PaymentMethodUpdateIn,
23
+ PayoutOut,
24
+ PriceCreateIn,
25
+ PriceOut,
26
+ PriceUpdateIn,
27
+ ProductCreateIn,
28
+ ProductOut,
29
+ ProductUpdateIn,
16
30
  RefundIn,
31
+ RefundOut,
32
+ SetupIntentCreateIn,
33
+ SetupIntentOut,
34
+ SubscriptionCreateIn,
35
+ SubscriptionOut,
36
+ SubscriptionUpdateIn,
17
37
  UsageRecordIn,
38
+ UsageRecordListFilter,
39
+ UsageRecordOut,
18
40
  )
19
41
  from ..settings import get_payments_settings
20
42
  from .base import ProviderAdapter
@@ -56,6 +78,110 @@ def _inv_to_out(inv) -> InvoiceOut:
56
78
  )
57
79
 
58
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
+
59
185
  class StripeAdapter(ProviderAdapter):
60
186
  name = "stripe"
61
187
 
@@ -70,8 +196,8 @@ class StripeAdapter(ProviderAdapter):
70
196
  st.stripe.webhook_secret.get_secret_value() if st.stripe.webhook_secret else None
71
197
  )
72
198
 
199
+ # -------- Customers --------
73
200
  async def ensure_customer(self, data: CustomerUpsertIn) -> CustomerOut:
74
- # try by email (idempotent enough for demo; production can map via your DB)
75
201
  if data.email:
76
202
  existing = await _acall(stripe.Customer.list, email=data.email, limit=1)
77
203
  c = (
@@ -108,113 +234,265 @@ class StripeAdapter(ProviderAdapter):
108
234
  name=c.get("name"),
109
235
  )
110
236
 
111
- async def create_intent(self, data: IntentCreateIn, *, user_id: str | None) -> IntentOut:
112
- kwargs: dict[str, Any] = dict(
113
- amount=int(data.amount),
114
- currency=data.currency.lower(),
115
- description=data.description or None,
116
- capture_method="manual" if data.capture_method == "manual" else "automatic",
117
- automatic_payment_methods={"enabled": True} if not data.payment_method_types else None,
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,
118
265
  )
119
- if data.payment_method_types:
120
- kwargs["payment_method_types"] = data.payment_method_types
121
- pi = await _acall(
122
- stripe.PaymentIntent.create,
123
- **{k: v for k, v in kwargs.items() if v is not None},
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
124
289
  )
125
- return IntentOut(
126
- id=pi.id,
127
- provider="stripe",
128
- provider_intent_id=pi.id,
129
- status=pi.status,
130
- amount=int(pi.amount),
131
- currency=pi.currency.upper(),
132
- client_secret=pi.client_secret,
133
- next_action=NextAction(type=getattr(getattr(pi, "next_action", None), "type", None)),
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
134
317
  )
318
+ return _pm_to_out(pm, is_default=is_default)
135
319
 
136
- async def confirm_intent(self, provider_intent_id: str) -> IntentOut:
137
- pi = await _acall(stripe.PaymentIntent.confirm, provider_intent_id)
138
- return IntentOut(
139
- id=pi.id,
140
- provider="stripe",
141
- provider_intent_id=pi.id,
142
- status=pi.status,
143
- amount=int(pi.amount),
144
- currency=pi.currency.upper(),
145
- client_secret=getattr(pi, "client_secret", None),
146
- next_action=NextAction(type=getattr(getattr(pi, "next_action", None), "type", None)),
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)
147
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))
148
356
 
149
- async def cancel_intent(self, provider_intent_id: str) -> IntentOut:
150
- pi = await _acall(stripe.PaymentIntent.cancel, provider_intent_id)
151
- return IntentOut(
152
- id=pi.id,
153
- provider="stripe",
154
- provider_intent_id=pi.id,
155
- status=pi.status,
156
- amount=int(pi.amount),
157
- currency=pi.currency.upper(),
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)
158
389
  )
390
+ return _product_to_out(p)
159
391
 
160
- async def refund(self, provider_intent_id: str, data: RefundIn) -> IntentOut:
161
- # Stripe refunds are created against charges; simplify via PaymentIntent last charge
162
- pi = await _acall(
163
- stripe.PaymentIntent.retrieve, provider_intent_id, expand=["latest_charge"]
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),
164
398
  )
165
- charge_id = pi.latest_charge.id if getattr(pi, "latest_charge", None) else None
166
- if not charge_id:
167
- raise ValueError("No charge available to refund")
168
- await _acall(
169
- stripe.Refund.create,
170
- charge=charge_id,
171
- amount=int(data.amount) if data.amount else None,
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)
172
439
  )
173
- # Re-hydrate
174
- return await self.hydrate_intent(provider_intent_id)
440
+ return _price_to_out(pr)
175
441
 
176
- async def verify_and_parse_webhook(
177
- self, signature: str | None, payload: bytes
178
- ) -> dict[str, Any]:
179
- if not self._wh_secret:
180
- raise ValueError("Stripe webhook secret not configured")
181
- event = await _acall(
182
- stripe.Webhook.construct_event,
183
- payload=payload,
184
- sig_header=signature,
185
- secret=self._wh_secret,
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,
186
448
  )
187
- return {"id": event.id, "type": event.type, "data": event.data.object}
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)
188
453
 
189
- async def hydrate_intent(self, provider_intent_id: str) -> IntentOut:
190
- pi = await _acall(stripe.PaymentIntent.retrieve, provider_intent_id)
191
- return IntentOut(
192
- id=pi.id,
193
- provider="stripe",
194
- provider_intent_id=pi.id,
195
- status=pi.status,
196
- amount=int(pi.amount),
197
- currency=pi.currency.upper(),
198
- client_secret=getattr(pi, "client_secret", None),
199
- next_action=NextAction(type=getattr(getattr(pi, "next_action", None), "type", None)),
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}),
200
481
  )
482
+ return _sub_to_out(s)
201
483
 
202
- async def capture_intent(self, provider_intent_id: str, *, amount: int | None) -> IntentOut:
203
- # Stripe: capture on PaymentIntent
204
- kwargs = {}
205
- if amount is not None:
206
- kwargs["amount_to_capture"] = int(amount)
207
- pi = await _acall(stripe.PaymentIntent.capture, provider_intent_id, **kwargs)
208
- return _pi_to_out(pi)
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)
209
487
 
210
- async def list_intents(
488
+ async def list_subscriptions(
211
489
  self,
212
490
  *,
213
491
  customer_provider_id: str | None,
214
492
  status: str | None,
215
493
  limit: int,
216
494
  cursor: str | None,
217
- ) -> tuple[list[IntentOut], str | None]:
495
+ ) -> tuple[list[SubscriptionOut], str | None]:
218
496
  params = {"limit": int(limit)}
219
497
  if customer_provider_id:
220
498
  params["customer"] = customer_provider_id
@@ -222,16 +500,40 @@ class StripeAdapter(ProviderAdapter):
222
500
  params["status"] = status
223
501
  if cursor:
224
502
  params["starting_after"] = cursor
225
- res = await _acall(stripe.PaymentIntent.list, **params)
226
- items = [_pi_to_out(pi) for pi in res.data]
503
+ res = await _acall(stripe.Subscription.list, **params)
504
+ items = [_sub_to_out(s) for s in res.data]
227
505
  next_cursor = res.data[-1].id if getattr(res, "has_more", False) and res.data else None
228
506
  return items, next_cursor
229
507
 
230
- # ---- Invoice helpers ----
231
- async def add_invoice_line_item(self, data: InvoiceLineItemIn) -> dict[str, Any]:
232
- kwargs = dict(
508
+ # -------- Invoices --------
509
+ async def create_invoice(self, data: InvoiceCreateIn) -> InvoiceOut:
510
+ inv = await _acall(
511
+ stripe.Invoice.create,
233
512
  customer=data.customer_provider_id,
234
- quantity=data.quantity or 1,
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),
235
537
  currency=data.currency.lower(),
236
538
  description=data.description or None,
237
539
  )
@@ -239,10 +541,11 @@ class StripeAdapter(ProviderAdapter):
239
541
  kwargs["price"] = data.provider_price_id
240
542
  else:
241
543
  kwargs["unit_amount"] = int(data.unit_amount)
242
- item = await _acall(
544
+ await _acall(
243
545
  stripe.InvoiceItem.create, **{k: v for k, v in kwargs.items() if v is not None}
244
546
  )
245
- return {"id": item.id}
547
+ inv = await _acall(stripe.Invoice.retrieve, provider_invoice_id)
548
+ return _inv_to_out(inv)
246
549
 
247
550
  async def list_invoices(
248
551
  self,
@@ -277,21 +580,229 @@ class StripeAdapter(ProviderAdapter):
277
580
  inv = await _acall(stripe.Invoice.upcoming, **params)
278
581
  return _inv_to_out(inv)
279
582
 
280
- # ---- Metered usage ----
281
- async def create_usage_record(self, data: UsageRecordIn) -> dict[str, Any]:
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:
282
796
  if not data.subscription_item and not data.provider_price_id:
283
797
  raise ValueError("subscription_item or provider_price_id is required")
284
- # If a price is given, you’d normally look up the active subscription_item for that price.
285
798
  sub_item = data.subscription_item
286
799
  if not sub_item and data.provider_price_id:
287
- # best-effort: find an active subscription item for the price
288
800
  items = await _acall(
289
801
  stripe.SubscriptionItem.list, price=data.provider_price_id, limit=1
290
802
  )
291
803
  sub_item = items.data[0].id if items.data else None
292
804
  if not sub_item:
293
805
  raise ValueError("No subscription item found for usage record")
294
-
295
806
  body = {
296
807
  "subscription_item": sub_item,
297
808
  "quantity": int(data.quantity),
@@ -300,4 +811,63 @@ class StripeAdapter(ProviderAdapter):
300
811
  if data.timestamp:
301
812
  body["timestamp"] = int(data.timestamp)
302
813
  rec = await _acall(stripe.UsageRecord.create, **body)
303
- return {"id": rec.id, "quantity": rec.quantity, "timestamp": rec.timestamp}
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}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: svc-infra
3
- Version: 0.1.586
3
+ Version: 0.1.587
4
4
  Summary: Infrastructure for building and deploying prod-ready services
5
5
  License: MIT
6
6
  Keywords: fastapi,sqlalchemy,alembic,auth,infra,async,pydantic
@@ -5,7 +5,7 @@ svc_infra/apf_payments/models.py,sha256=u4U5oszha5uulCIrNoajaFDIc5YmTlh2mtm-yJUv
5
5
  svc_infra/apf_payments/provider/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  svc_infra/apf_payments/provider/base.py,sha256=1t5znglpGFhjt4zdbuzE5VlHvGarFwzH2oscK8yyKNY,7678
7
7
  svc_infra/apf_payments/provider/registry.py,sha256=NZ4pUkFcbXNtqWEpFeI3NwoKRYGWe9fVQakmlrVLTKE,878
8
- svc_infra/apf_payments/provider/stripe.py,sha256=rZlQe1BBLBF1aMOqw98aDRZkA7pOgTqkGvXzSG8qE5c,11298
8
+ svc_infra/apf_payments/provider/stripe.py,sha256=Xb_UjdobbBzK-an9cO1jRWiP6OHvki5MDp6JnS6a1-I,34392
9
9
  svc_infra/apf_payments/schemas.py,sha256=XfBxx6z_Y6cdHktanafNDhhgWl8JVvag9vp2ORmvn_4,8403
10
10
  svc_infra/apf_payments/service.py,sha256=bn3BTOTdfkJ4b0Z9cHuFHvlMcv9B1b2n0v-unveUplA,31060
11
11
  svc_infra/apf_payments/settings.py,sha256=VnNQbajbv843buUisqa82xOQ-f5JA8JzHD8o01-yhPQ,1239
@@ -228,7 +228,7 @@ svc_infra/obs/templates/sidecars/railway/__init__.py,sha256=47DEQpj8HBSa-_TImW-5
228
228
  svc_infra/obs/templates/sidecars/railway/agent.yaml,sha256=hYv35yG92XEP_4joMFmMcVTD-4fG_zHitmChjreUJh4,516
229
229
  svc_infra/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
230
230
  svc_infra/utils.py,sha256=VX1yjTx61-YvAymyRhGy18DhybiVdPddiYD_FlKTbJU,952
231
- svc_infra-0.1.586.dist-info/METADATA,sha256=4SPlQZV0C5x9xvRO_yiUSII9AcRksY1L3Wg6JzfasHM,3487
232
- svc_infra-0.1.586.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
233
- svc_infra-0.1.586.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
234
- svc_infra-0.1.586.dist-info/RECORD,,
231
+ svc_infra-0.1.587.dist-info/METADATA,sha256=hirPy7fbFdNdvqQWb1ycjYOl7sqPuJHgZPcQO4LDGf8,3487
232
+ svc_infra-0.1.587.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
233
+ svc_infra-0.1.587.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
234
+ svc_infra-0.1.587.dist-info/RECORD,,