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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/models.py +142 -4
  3. svc_infra/apf_payments/provider/__init__.py +4 -0
  4. svc_infra/apf_payments/provider/aiydan.py +797 -0
  5. svc_infra/apf_payments/provider/base.py +178 -12
  6. svc_infra/apf_payments/provider/stripe.py +757 -48
  7. svc_infra/apf_payments/schemas.py +163 -1
  8. svc_infra/apf_payments/service.py +582 -42
  9. svc_infra/apf_payments/settings.py +22 -2
  10. svc_infra/api/fastapi/admin/__init__.py +3 -0
  11. svc_infra/api/fastapi/admin/add.py +231 -0
  12. svc_infra/api/fastapi/apf_payments/router.py +792 -73
  13. svc_infra/api/fastapi/apf_payments/setup.py +13 -4
  14. svc_infra/api/fastapi/auth/add.py +10 -4
  15. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  16. svc_infra/api/fastapi/auth/routers/oauth_router.py +74 -34
  17. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  18. svc_infra/api/fastapi/auth/settings.py +2 -0
  19. svc_infra/api/fastapi/billing/router.py +64 -0
  20. svc_infra/api/fastapi/billing/setup.py +19 -0
  21. svc_infra/api/fastapi/cache/add.py +9 -5
  22. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  23. svc_infra/api/fastapi/db/sql/add.py +40 -18
  24. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  25. svc_infra/api/fastapi/db/sql/session.py +16 -0
  26. svc_infra/api/fastapi/db/sql/users.py +13 -1
  27. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  28. svc_infra/api/fastapi/docs/add.py +160 -0
  29. svc_infra/api/fastapi/docs/landing.py +1 -1
  30. svc_infra/api/fastapi/docs/scoped.py +41 -6
  31. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  32. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  33. svc_infra/api/fastapi/middleware/idempotency.py +82 -42
  34. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  35. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  36. svc_infra/api/fastapi/middleware/ratelimit.py +84 -11
  37. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  38. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  39. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  40. svc_infra/api/fastapi/openapi/mutators.py +244 -38
  41. svc_infra/api/fastapi/ops/add.py +73 -0
  42. svc_infra/api/fastapi/pagination.py +133 -32
  43. svc_infra/api/fastapi/routers/ping.py +1 -0
  44. svc_infra/api/fastapi/setup.py +23 -14
  45. svc_infra/api/fastapi/tenancy/add.py +19 -0
  46. svc_infra/api/fastapi/tenancy/context.py +112 -0
  47. svc_infra/api/fastapi/versioned.py +101 -0
  48. svc_infra/app/README.md +5 -5
  49. svc_infra/billing/__init__.py +23 -0
  50. svc_infra/billing/async_service.py +147 -0
  51. svc_infra/billing/jobs.py +230 -0
  52. svc_infra/billing/models.py +131 -0
  53. svc_infra/billing/quotas.py +101 -0
  54. svc_infra/billing/schemas.py +33 -0
  55. svc_infra/billing/service.py +115 -0
  56. svc_infra/bundled_docs/README.md +5 -0
  57. svc_infra/bundled_docs/__init__.py +1 -0
  58. svc_infra/bundled_docs/getting-started.md +6 -0
  59. svc_infra/cache/__init__.py +4 -0
  60. svc_infra/cache/add.py +158 -0
  61. svc_infra/cache/backend.py +5 -2
  62. svc_infra/cache/decorators.py +19 -1
  63. svc_infra/cache/keys.py +24 -4
  64. svc_infra/cli/__init__.py +32 -8
  65. svc_infra/cli/__main__.py +4 -0
  66. svc_infra/cli/cmds/__init__.py +10 -0
  67. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  68. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  69. svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
  70. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  71. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  72. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  73. svc_infra/cli/cmds/dx/__init__.py +12 -0
  74. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  75. svc_infra/cli/cmds/help.py +4 -0
  76. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  77. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  78. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  79. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  80. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  81. svc_infra/data/add.py +61 -0
  82. svc_infra/data/backup.py +53 -0
  83. svc_infra/data/erasure.py +45 -0
  84. svc_infra/data/fixtures.py +40 -0
  85. svc_infra/data/retention.py +55 -0
  86. svc_infra/db/inbox.py +67 -0
  87. svc_infra/db/nosql/mongo/README.md +13 -13
  88. svc_infra/db/outbox.py +104 -0
  89. svc_infra/db/sql/repository.py +52 -12
  90. svc_infra/db/sql/resource.py +5 -0
  91. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  92. svc_infra/db/sql/templates/setup/env_async.py.tmpl +13 -8
  93. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +9 -5
  94. svc_infra/db/sql/tenant.py +79 -0
  95. svc_infra/db/sql/utils.py +18 -4
  96. svc_infra/db/sql/versioning.py +14 -0
  97. svc_infra/docs/acceptance-matrix.md +71 -0
  98. svc_infra/docs/acceptance.md +44 -0
  99. svc_infra/docs/admin.md +425 -0
  100. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  101. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  102. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  103. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  104. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  105. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  106. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  107. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  108. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  109. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  110. svc_infra/docs/api.md +59 -0
  111. svc_infra/docs/auth.md +11 -0
  112. svc_infra/docs/billing.md +190 -0
  113. svc_infra/docs/cache.md +76 -0
  114. svc_infra/docs/cli.md +74 -0
  115. svc_infra/docs/contributing.md +34 -0
  116. svc_infra/docs/data-lifecycle.md +52 -0
  117. svc_infra/docs/database.md +14 -0
  118. svc_infra/docs/docs-and-sdks.md +62 -0
  119. svc_infra/docs/environment.md +114 -0
  120. svc_infra/docs/getting-started.md +63 -0
  121. svc_infra/docs/idempotency.md +111 -0
  122. svc_infra/docs/jobs.md +67 -0
  123. svc_infra/docs/observability.md +16 -0
  124. svc_infra/docs/ops.md +37 -0
  125. svc_infra/docs/rate-limiting.md +125 -0
  126. svc_infra/docs/repo-review.md +48 -0
  127. svc_infra/docs/security.md +176 -0
  128. svc_infra/docs/tenancy.md +35 -0
  129. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  130. svc_infra/docs/versioned-integrations.md +146 -0
  131. svc_infra/docs/webhooks.md +112 -0
  132. svc_infra/dx/add.py +63 -0
  133. svc_infra/dx/changelog.py +74 -0
  134. svc_infra/dx/checks.py +67 -0
  135. svc_infra/http/__init__.py +13 -0
  136. svc_infra/http/client.py +72 -0
  137. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  138. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  139. svc_infra/jobs/easy.py +32 -0
  140. svc_infra/jobs/loader.py +45 -0
  141. svc_infra/jobs/queue.py +81 -0
  142. svc_infra/jobs/redis_queue.py +191 -0
  143. svc_infra/jobs/runner.py +75 -0
  144. svc_infra/jobs/scheduler.py +41 -0
  145. svc_infra/jobs/worker.py +40 -0
  146. svc_infra/mcp/svc_infra_mcp.py +85 -28
  147. svc_infra/obs/README.md +2 -0
  148. svc_infra/obs/add.py +54 -7
  149. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  150. svc_infra/obs/metrics/__init__.py +53 -0
  151. svc_infra/obs/metrics.py +52 -0
  152. svc_infra/security/add.py +201 -0
  153. svc_infra/security/audit.py +130 -0
  154. svc_infra/security/audit_service.py +73 -0
  155. svc_infra/security/headers.py +52 -0
  156. svc_infra/security/hibp.py +95 -0
  157. svc_infra/security/jwt_rotation.py +53 -0
  158. svc_infra/security/lockout.py +96 -0
  159. svc_infra/security/models.py +255 -0
  160. svc_infra/security/org_invites.py +128 -0
  161. svc_infra/security/passwords.py +77 -0
  162. svc_infra/security/permissions.py +149 -0
  163. svc_infra/security/session.py +98 -0
  164. svc_infra/security/signed_cookies.py +80 -0
  165. svc_infra/webhooks/__init__.py +16 -0
  166. svc_infra/webhooks/add.py +322 -0
  167. svc_infra/webhooks/fastapi.py +37 -0
  168. svc_infra/webhooks/router.py +55 -0
  169. svc_infra/webhooks/service.py +67 -0
  170. svc_infra/webhooks/signing.py +30 -0
  171. svc_infra-0.1.654.dist-info/METADATA +154 -0
  172. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/RECORD +174 -56
  173. svc_infra-0.1.562.dist-info/METADATA +0 -79
  174. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  175. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,797 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from datetime import date, datetime, timezone
5
+ from typing import Any, Optional, Sequence, Tuple
6
+
7
+ from svc_infra.apf_payments.schemas import (
8
+ BalanceAmount,
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 svc_infra.apf_payments.settings import get_payments_settings
42
+
43
+ from .base import ProviderAdapter
44
+
45
+ try: # pragma: no cover - optional dependency
46
+ import aiydan # type: ignore
47
+ except Exception: # pragma: no cover - handled at runtime
48
+ aiydan = None # type: ignore
49
+
50
+
51
+ async def _maybe_await(result: Any) -> Any:
52
+ if inspect.isawaitable(result):
53
+ return await result
54
+ return result
55
+
56
+
57
+ def _coerce_id(data: dict[str, Any], *candidates: str) -> str:
58
+ for key in candidates:
59
+ value = data.get(key)
60
+ if isinstance(value, str) and value:
61
+ return value
62
+ raise RuntimeError(f"Aiydan payload missing id fields: {candidates}")
63
+
64
+
65
+ def _ensure_utc_isoformat(value: Any) -> Optional[str]:
66
+ if value is None:
67
+ return None
68
+ if isinstance(value, str):
69
+ return value
70
+ if isinstance(value, datetime):
71
+ if value.tzinfo is None:
72
+ value = value.replace(tzinfo=timezone.utc)
73
+ return value.astimezone(timezone.utc).isoformat()
74
+ if isinstance(value, date):
75
+ return datetime(value.year, value.month, value.day, tzinfo=timezone.utc).isoformat()
76
+ try:
77
+ parsed = datetime.fromisoformat(str(value))
78
+ if parsed.tzinfo is None:
79
+ parsed = parsed.replace(tzinfo=timezone.utc)
80
+ return parsed.astimezone(timezone.utc).isoformat()
81
+ except Exception:
82
+ return str(value)
83
+
84
+
85
+ def _customer_to_out(data: dict[str, Any]) -> CustomerOut:
86
+ cust_id = _coerce_id(data, "provider_customer_id", "customer_id", "id")
87
+ return CustomerOut(
88
+ id=cust_id,
89
+ provider="aiydan",
90
+ provider_customer_id=cust_id,
91
+ email=data.get("email"),
92
+ name=data.get("name"),
93
+ )
94
+
95
+
96
+ def _intent_to_out(data: dict[str, Any]) -> IntentOut:
97
+ intent_id = _coerce_id(data, "provider_intent_id", "intent_id", "id")
98
+ return IntentOut(
99
+ id=intent_id,
100
+ provider="aiydan",
101
+ provider_intent_id=intent_id,
102
+ status=str(data.get("status", "")),
103
+ amount=int(data.get("amount", 0)),
104
+ currency=str(data.get("currency", "")).upper(),
105
+ client_secret=data.get("client_secret"),
106
+ next_action=NextAction(type=(data.get("next_action") or {}).get("type")),
107
+ )
108
+
109
+
110
+ def _payment_method_to_out(data: dict[str, Any]) -> PaymentMethodOut:
111
+ method_id = _coerce_id(data, "provider_method_id", "payment_method_id", "id")
112
+ card = data.get("card") or {}
113
+ return PaymentMethodOut(
114
+ id=method_id,
115
+ provider="aiydan",
116
+ provider_customer_id=str(data.get("provider_customer_id") or data.get("customer_id") or ""),
117
+ provider_method_id=method_id,
118
+ brand=card.get("brand") or data.get("brand"),
119
+ last4=card.get("last4") or data.get("last4"),
120
+ exp_month=card.get("exp_month") or data.get("exp_month"),
121
+ exp_year=card.get("exp_year") or data.get("exp_year"),
122
+ is_default=bool(data.get("is_default")),
123
+ )
124
+
125
+
126
+ def _product_to_out(data: dict[str, Any]) -> ProductOut:
127
+ product_id = _coerce_id(data, "provider_product_id", "product_id", "id")
128
+ return ProductOut(
129
+ id=product_id,
130
+ provider="aiydan",
131
+ provider_product_id=product_id,
132
+ name=str(data.get("name", "")),
133
+ active=bool(data.get("active", True)),
134
+ )
135
+
136
+
137
+ def _price_to_out(data: dict[str, Any]) -> PriceOut:
138
+ price_id = _coerce_id(data, "provider_price_id", "price_id", "id")
139
+ recurring = data.get("recurring") or {}
140
+ return PriceOut(
141
+ id=price_id,
142
+ provider="aiydan",
143
+ provider_price_id=price_id,
144
+ provider_product_id=str(
145
+ data.get("provider_product_id")
146
+ or data.get("product_id")
147
+ or getattr(data.get("product"), "id", "")
148
+ ),
149
+ currency=str(data.get("currency", "")).upper(),
150
+ unit_amount=int(data.get("unit_amount", data.get("amount", 0) or 0)),
151
+ interval=str(recurring.get("interval")) if recurring.get("interval") else None,
152
+ trial_days=data.get("trial_days"),
153
+ active=bool(data.get("active", True)),
154
+ )
155
+
156
+
157
+ def _subscription_to_out(data: dict[str, Any]) -> SubscriptionOut:
158
+ sub_id = _coerce_id(data, "provider_subscription_id", "subscription_id", "id")
159
+ items = data.get("items") or {}
160
+ first_item = None
161
+ if isinstance(items, dict):
162
+ first_item = (items.get("data") or [None])[0]
163
+ elif isinstance(items, Sequence):
164
+ first_item = items[0] if items else None
165
+ price_id = (
166
+ first_item.get("price")
167
+ if isinstance(first_item, dict)
168
+ else getattr(first_item, "price", None)
169
+ )
170
+ if isinstance(price_id, dict):
171
+ price_id = price_id.get("id")
172
+ elif price_id is not None and not isinstance(price_id, str):
173
+ price_id = getattr(price_id, "id", None)
174
+ quantity = (
175
+ first_item.get("quantity")
176
+ if isinstance(first_item, dict)
177
+ else getattr(first_item, "quantity", 0)
178
+ )
179
+ return SubscriptionOut(
180
+ id=sub_id,
181
+ provider="aiydan",
182
+ provider_subscription_id=sub_id,
183
+ provider_price_id=price_id or "",
184
+ status=str(data.get("status", "")),
185
+ quantity=int(quantity or 0),
186
+ cancel_at_period_end=bool(data.get("cancel_at_period_end", False)),
187
+ current_period_end=_ensure_utc_isoformat(data.get("current_period_end")),
188
+ )
189
+
190
+
191
+ def _invoice_to_out(data: dict[str, Any]) -> InvoiceOut:
192
+ invoice_id = _coerce_id(data, "provider_invoice_id", "invoice_id", "id")
193
+ return InvoiceOut(
194
+ id=invoice_id,
195
+ provider="aiydan",
196
+ provider_invoice_id=invoice_id,
197
+ provider_customer_id=str(data.get("provider_customer_id") or data.get("customer_id") or ""),
198
+ status=str(data.get("status", "")),
199
+ amount_due=int(data.get("amount_due", data.get("amount") or 0) or 0),
200
+ currency=str(data.get("currency", "")).upper(),
201
+ hosted_invoice_url=data.get("hosted_invoice_url") or data.get("hosted_url"),
202
+ pdf_url=data.get("pdf_url") or data.get("invoice_pdf"),
203
+ )
204
+
205
+
206
+ def _invoice_line_item_to_out(data: dict[str, Any]) -> InvoiceLineItemOut:
207
+ line_id = _coerce_id(data, "provider_invoice_line_item_id", "line_id", "id")
208
+ price = data.get("price") or {}
209
+ if not isinstance(price, dict):
210
+ price = {"id": getattr(price, "id", None)}
211
+ return InvoiceLineItemOut(
212
+ id=line_id,
213
+ description=data.get("description"),
214
+ currency=str(data.get("currency", price.get("currency", ""))).upper(),
215
+ quantity=int(data.get("quantity", 0) or 0),
216
+ unit_amount=int(data.get("unit_amount", data.get("amount", 0) or 0)),
217
+ provider_price_id=price.get("id"),
218
+ )
219
+
220
+
221
+ def _refund_to_out(data: dict[str, Any]) -> RefundOut:
222
+ refund_id = _coerce_id(data, "provider_refund_id", "refund_id", "id")
223
+ return RefundOut(
224
+ id=refund_id,
225
+ provider="aiydan",
226
+ provider_refund_id=refund_id,
227
+ provider_payment_intent_id=str(
228
+ data.get("provider_payment_intent_id") or data.get("payment_intent_id") or ""
229
+ ),
230
+ amount=int(data.get("amount", 0) or 0),
231
+ currency=str(data.get("currency", "")).upper(),
232
+ status=str(data.get("status", "")),
233
+ reason=data.get("reason"),
234
+ created_at=_ensure_utc_isoformat(data.get("created_at") or data.get("created")),
235
+ )
236
+
237
+
238
+ def _dispute_to_out(data: dict[str, Any]) -> DisputeOut:
239
+ dispute_id = _coerce_id(data, "provider_dispute_id", "dispute_id", "id")
240
+ evidence = data.get("evidence") or {}
241
+ return DisputeOut(
242
+ id=dispute_id,
243
+ provider="aiydan",
244
+ provider_dispute_id=dispute_id,
245
+ amount=int(data.get("amount", 0) or 0),
246
+ currency=str(data.get("currency", "")).upper(),
247
+ reason=data.get("reason"),
248
+ status=str(data.get("status", "")),
249
+ evidence_due_by=_ensure_utc_isoformat(
250
+ evidence.get("due_by") or data.get("evidence_due_by")
251
+ ),
252
+ created_at=_ensure_utc_isoformat(data.get("created_at") or data.get("created")),
253
+ )
254
+
255
+
256
+ def _payout_to_out(data: dict[str, Any]) -> PayoutOut:
257
+ payout_id = _coerce_id(data, "provider_payout_id", "payout_id", "id")
258
+ return PayoutOut(
259
+ id=payout_id,
260
+ provider="aiydan",
261
+ provider_payout_id=payout_id,
262
+ amount=int(data.get("amount", 0) or 0),
263
+ currency=str(data.get("currency", "")).upper(),
264
+ status=str(data.get("status", "")),
265
+ arrival_date=_ensure_utc_isoformat(data.get("arrival_date")),
266
+ type=data.get("type"),
267
+ )
268
+
269
+
270
+ def _usage_record_to_out(data: dict[str, Any]) -> UsageRecordOut:
271
+ return UsageRecordOut(
272
+ id=str(data.get("id")),
273
+ quantity=int(data.get("quantity", 0) or 0),
274
+ timestamp=data.get("timestamp"),
275
+ subscription_item=(
276
+ str(data.get("subscription_item")) if data.get("subscription_item") else None
277
+ ),
278
+ provider_price_id=(
279
+ str(data.get("provider_price_id")) if data.get("provider_price_id") else None
280
+ ),
281
+ action=(str(data.get("action")) if data.get("action") else None),
282
+ )
283
+
284
+
285
+ def _balance_snapshot_to_out(data: dict[str, Any]) -> BalanceSnapshotOut:
286
+ def _normalize(side: Any) -> list[dict[str, Any]]:
287
+ if isinstance(side, list):
288
+ out: list[dict[str, Any]] = []
289
+ for item in side:
290
+ if isinstance(item, dict) and "currency" in item and "amount" in item:
291
+ out.append(
292
+ {
293
+ "currency": str(item["currency"]).upper(),
294
+ "amount": int(item["amount"] or 0),
295
+ }
296
+ )
297
+ return out
298
+ if isinstance(side, dict):
299
+ return [
300
+ {"currency": str(cur).upper(), "amount": int(amt or 0)} for cur, amt in side.items()
301
+ ]
302
+ return []
303
+
304
+ return BalanceSnapshotOut(
305
+ available=[
306
+ BalanceAmount(currency=i["currency"], amount=i["amount"])
307
+ for i in _normalize(data.get("available"))
308
+ ],
309
+ pending=[
310
+ BalanceAmount(currency=i["currency"], amount=i["amount"])
311
+ for i in _normalize(data.get("pending"))
312
+ ],
313
+ )
314
+
315
+
316
+ def _ensure_sequence(result: Any) -> Sequence[dict[str, Any]]:
317
+ if isinstance(result, Sequence):
318
+ return result # type: ignore[arg-type]
319
+ if isinstance(result, dict):
320
+ items = result.get("items")
321
+ if isinstance(items, Sequence):
322
+ return items # type: ignore[arg-type]
323
+ raise RuntimeError("Expected sequence payload from Aiydan client")
324
+
325
+
326
+ def _ensure_list_response(result: Any) -> Tuple[Sequence[dict[str, Any]], Optional[str]]:
327
+ if isinstance(result, tuple) and len(result) == 2:
328
+ items, cursor = result
329
+ if isinstance(items, Sequence) or items is None:
330
+ return (items or []), cursor # type: ignore[arg-type]
331
+ if isinstance(result, dict):
332
+ items = result.get("items")
333
+ cursor = result.get("next_cursor") or result.get("cursor")
334
+ if isinstance(items, Sequence):
335
+ return items, cursor
336
+ if isinstance(result, Sequence):
337
+ return result, None # type: ignore[arg-type]
338
+ raise RuntimeError("Expected iterable response from Aiydan client")
339
+
340
+
341
+ class AiydanAdapter(ProviderAdapter):
342
+ name = "aiydan"
343
+
344
+ def __init__(self, *, client: Optional[Any] = None):
345
+ settings = get_payments_settings()
346
+ cfg = settings.aiydan
347
+ if client is not None:
348
+ self._client = client
349
+ self._webhook_secret = (
350
+ cfg.webhook_secret.get_secret_value() if cfg and cfg.webhook_secret else None
351
+ )
352
+ return
353
+ if cfg is None:
354
+ raise RuntimeError("Aiydan settings not configured")
355
+ if aiydan is None:
356
+ raise RuntimeError("aiydan SDK is not installed. pip install aiydan")
357
+ client_class = getattr(aiydan, "Client", None)
358
+ if client_class is None:
359
+ raise RuntimeError("aiydan SDK missing 'Client' class")
360
+ kwargs: dict[str, Any] = {"api_key": cfg.api_key.get_secret_value()}
361
+ if cfg.client_key:
362
+ kwargs["client_key"] = cfg.client_key.get_secret_value()
363
+ if cfg.merchant_account:
364
+ kwargs["merchant_account"] = cfg.merchant_account
365
+ if cfg.hmac_key:
366
+ kwargs["hmac_key"] = cfg.hmac_key.get_secret_value()
367
+ if cfg.base_url:
368
+ kwargs["base_url"] = cfg.base_url
369
+ self._client = client_class(**kwargs)
370
+ self._webhook_secret = cfg.webhook_secret.get_secret_value() if cfg.webhook_secret else None
371
+
372
+ async def ensure_customer(self, data: CustomerUpsertIn) -> CustomerOut:
373
+ payload = data.model_dump(exclude_none=True)
374
+ result = await _maybe_await(self._client.ensure_customer(payload))
375
+ return _customer_to_out(result)
376
+
377
+ async def attach_payment_method(self, data: PaymentMethodAttachIn) -> PaymentMethodOut:
378
+ payload = data.model_dump(exclude_none=True)
379
+ result = await _maybe_await(self._client.attach_payment_method(payload))
380
+ return _payment_method_to_out(result)
381
+
382
+ async def list_payment_methods(self, provider_customer_id: str) -> list[PaymentMethodOut]:
383
+ result = await _maybe_await(self._client.list_payment_methods(provider_customer_id))
384
+ methods = _ensure_sequence(result)
385
+ return [_payment_method_to_out(method) for method in methods]
386
+
387
+ async def detach_payment_method(self, provider_method_id: str) -> PaymentMethodOut:
388
+ result = await _maybe_await(self._client.detach_payment_method(provider_method_id))
389
+ return _payment_method_to_out(result)
390
+
391
+ async def set_default_payment_method(
392
+ self, provider_customer_id: str, provider_method_id: str
393
+ ) -> PaymentMethodOut:
394
+ result = await _maybe_await(
395
+ self._client.set_default_payment_method(provider_customer_id, provider_method_id)
396
+ )
397
+ return _payment_method_to_out(result)
398
+
399
+ async def get_payment_method(self, provider_method_id: str) -> PaymentMethodOut:
400
+ result = await _maybe_await(self._client.get_payment_method(provider_method_id))
401
+ return _payment_method_to_out(result)
402
+
403
+ async def update_payment_method(
404
+ self, provider_method_id: str, data: PaymentMethodUpdateIn
405
+ ) -> PaymentMethodOut:
406
+ payload = data.model_dump(exclude_none=True)
407
+ result = await _maybe_await(self._client.update_payment_method(provider_method_id, payload))
408
+ return _payment_method_to_out(result)
409
+
410
+ async def create_product(self, data: ProductCreateIn) -> ProductOut:
411
+ payload = data.model_dump(exclude_none=True)
412
+ result = await _maybe_await(self._client.create_product(payload))
413
+ return _product_to_out(result)
414
+
415
+ async def get_product(self, provider_product_id: str) -> ProductOut:
416
+ result = await _maybe_await(self._client.get_product(provider_product_id))
417
+ return _product_to_out(result)
418
+
419
+ async def list_products(
420
+ self, *, active: bool | None, limit: int, cursor: str | None
421
+ ) -> tuple[list[ProductOut], str | None]:
422
+ result = await _maybe_await(
423
+ self._client.list_products(active=active, limit=limit, cursor=cursor)
424
+ )
425
+ items, next_cursor = _ensure_list_response(result)
426
+ return [_product_to_out(item) for item in items], next_cursor
427
+
428
+ async def update_product(self, provider_product_id: str, data: ProductUpdateIn) -> ProductOut:
429
+ payload = data.model_dump(exclude_none=True)
430
+ result = await _maybe_await(self._client.update_product(provider_product_id, payload))
431
+ return _product_to_out(result)
432
+
433
+ async def create_price(self, data: PriceCreateIn) -> PriceOut:
434
+ payload = data.model_dump(exclude_none=True)
435
+ result = await _maybe_await(self._client.create_price(payload))
436
+ return _price_to_out(result)
437
+
438
+ async def get_price(self, provider_price_id: str) -> PriceOut:
439
+ result = await _maybe_await(self._client.get_price(provider_price_id))
440
+ return _price_to_out(result)
441
+
442
+ async def list_prices(
443
+ self,
444
+ *,
445
+ provider_product_id: str | None,
446
+ active: bool | None,
447
+ limit: int,
448
+ cursor: str | None,
449
+ ) -> tuple[list[PriceOut], str | None]:
450
+ result = await _maybe_await(
451
+ self._client.list_prices(
452
+ provider_product_id=provider_product_id,
453
+ active=active,
454
+ limit=limit,
455
+ cursor=cursor,
456
+ )
457
+ )
458
+ items, next_cursor = _ensure_list_response(result)
459
+ return [_price_to_out(item) for item in items], next_cursor
460
+
461
+ async def update_price(self, provider_price_id: str, data: PriceUpdateIn) -> PriceOut:
462
+ payload = data.model_dump(exclude_none=True)
463
+ result = await _maybe_await(self._client.update_price(provider_price_id, payload))
464
+ return _price_to_out(result)
465
+
466
+ async def create_subscription(self, data: SubscriptionCreateIn) -> SubscriptionOut:
467
+ payload = data.model_dump(exclude_none=True)
468
+ result = await _maybe_await(self._client.create_subscription(payload))
469
+ return _subscription_to_out(result)
470
+
471
+ async def update_subscription(
472
+ self, provider_subscription_id: str, data: SubscriptionUpdateIn
473
+ ) -> SubscriptionOut:
474
+ payload = data.model_dump(exclude_none=True)
475
+ result = await _maybe_await(
476
+ self._client.update_subscription(provider_subscription_id, payload)
477
+ )
478
+ return _subscription_to_out(result)
479
+
480
+ async def cancel_subscription(
481
+ self, provider_subscription_id: str, at_period_end: bool = True
482
+ ) -> SubscriptionOut:
483
+ result = await _maybe_await(
484
+ self._client.cancel_subscription(provider_subscription_id, at_period_end)
485
+ )
486
+ return _subscription_to_out(result)
487
+
488
+ async def get_subscription(self, provider_subscription_id: str) -> SubscriptionOut:
489
+ result = await _maybe_await(self._client.get_subscription(provider_subscription_id))
490
+ return _subscription_to_out(result)
491
+
492
+ async def list_subscriptions(
493
+ self,
494
+ *,
495
+ customer_provider_id: str | None,
496
+ status: str | None,
497
+ limit: int,
498
+ cursor: str | None,
499
+ ) -> tuple[list[SubscriptionOut], str | None]:
500
+ result = await _maybe_await(
501
+ self._client.list_subscriptions(
502
+ customer_provider_id=customer_provider_id,
503
+ status=status,
504
+ limit=limit,
505
+ cursor=cursor,
506
+ )
507
+ )
508
+ items, next_cursor = _ensure_list_response(result)
509
+ return [_subscription_to_out(item) for item in items], next_cursor
510
+
511
+ async def create_invoice(self, data: InvoiceCreateIn) -> InvoiceOut:
512
+ payload = data.model_dump(exclude_none=True)
513
+ result = await _maybe_await(self._client.create_invoice(payload))
514
+ return _invoice_to_out(result)
515
+
516
+ async def finalize_invoice(self, provider_invoice_id: str) -> InvoiceOut:
517
+ result = await _maybe_await(self._client.finalize_invoice(provider_invoice_id))
518
+ return _invoice_to_out(result)
519
+
520
+ async def void_invoice(self, provider_invoice_id: str) -> InvoiceOut:
521
+ result = await _maybe_await(self._client.void_invoice(provider_invoice_id))
522
+ return _invoice_to_out(result)
523
+
524
+ async def pay_invoice(self, provider_invoice_id: str) -> InvoiceOut:
525
+ result = await _maybe_await(self._client.pay_invoice(provider_invoice_id))
526
+ return _invoice_to_out(result)
527
+
528
+ async def add_invoice_line_item(
529
+ self, provider_invoice_id: str, data: InvoiceLineItemIn
530
+ ) -> InvoiceOut:
531
+ payload = data.model_dump(exclude_none=True)
532
+ result = await _maybe_await(
533
+ self._client.add_invoice_line_item(provider_invoice_id, payload)
534
+ )
535
+ return _invoice_to_out(result)
536
+
537
+ async def list_invoices(
538
+ self,
539
+ *,
540
+ customer_provider_id: str | None,
541
+ status: str | None,
542
+ limit: int,
543
+ cursor: str | None,
544
+ ) -> tuple[list[InvoiceOut], str | None]:
545
+ result = await _maybe_await(
546
+ self._client.list_invoices(
547
+ customer_provider_id=customer_provider_id,
548
+ status=status,
549
+ limit=limit,
550
+ cursor=cursor,
551
+ )
552
+ )
553
+ items, next_cursor = _ensure_list_response(result)
554
+ return [_invoice_to_out(item) for item in items], next_cursor
555
+
556
+ async def get_invoice(self, provider_invoice_id: str) -> InvoiceOut:
557
+ result = await _maybe_await(self._client.get_invoice(provider_invoice_id))
558
+ return _invoice_to_out(result)
559
+
560
+ async def preview_invoice(
561
+ self, *, customer_provider_id: str, subscription_id: str | None = None
562
+ ) -> InvoiceOut:
563
+ result = await _maybe_await(
564
+ self._client.preview_invoice(
565
+ customer_provider_id=customer_provider_id,
566
+ subscription_id=subscription_id,
567
+ )
568
+ )
569
+ return _invoice_to_out(result)
570
+
571
+ async def list_invoice_line_items(
572
+ self, provider_invoice_id: str, *, limit: int, cursor: str | None
573
+ ) -> tuple[list[InvoiceLineItemOut], str | None]:
574
+ result = await _maybe_await(
575
+ self._client.list_invoice_line_items(
576
+ provider_invoice_id,
577
+ limit=limit,
578
+ cursor=cursor,
579
+ )
580
+ )
581
+ items, next_cursor = _ensure_list_response(result)
582
+ return [_invoice_line_item_to_out(item) for item in items], next_cursor
583
+
584
+ async def create_intent(self, data: IntentCreateIn, *, user_id: str | None) -> IntentOut:
585
+ payload = data.model_dump(exclude_none=True)
586
+ if user_id is not None:
587
+ payload["user_id"] = user_id
588
+ result = await _maybe_await(self._client.create_intent(payload))
589
+ return _intent_to_out(result)
590
+
591
+ async def confirm_intent(self, provider_intent_id: str) -> IntentOut:
592
+ result = await _maybe_await(self._client.confirm_intent(provider_intent_id))
593
+ return _intent_to_out(result)
594
+
595
+ async def cancel_intent(self, provider_intent_id: str) -> IntentOut:
596
+ result = await _maybe_await(self._client.cancel_intent(provider_intent_id))
597
+ return _intent_to_out(result)
598
+
599
+ async def refund(self, provider_intent_id: str, data: RefundIn) -> IntentOut:
600
+ payload = data.model_dump(exclude_none=True)
601
+ result = await _maybe_await(self._client.refund_intent(provider_intent_id, payload))
602
+ return _intent_to_out(result)
603
+
604
+ async def hydrate_intent(self, provider_intent_id: str) -> IntentOut:
605
+ result = await _maybe_await(self._client.get_intent(provider_intent_id))
606
+ return _intent_to_out(result)
607
+
608
+ async def capture_intent(self, provider_intent_id: str, *, amount: int | None) -> IntentOut:
609
+ result = await _maybe_await(self._client.capture_intent(provider_intent_id, amount=amount))
610
+ return _intent_to_out(result)
611
+
612
+ async def list_intents(
613
+ self,
614
+ *,
615
+ customer_provider_id: str | None,
616
+ status: str | None,
617
+ limit: int,
618
+ cursor: str | None,
619
+ ) -> tuple[list[IntentOut], str | None]:
620
+ result = await _maybe_await(
621
+ self._client.list_intents(
622
+ customer_provider_id=customer_provider_id,
623
+ status=status,
624
+ limit=limit,
625
+ cursor=cursor,
626
+ )
627
+ )
628
+ items, next_cursor = _ensure_list_response(result)
629
+ return [_intent_to_out(item) for item in items], next_cursor
630
+
631
+ async def verify_and_parse_webhook(
632
+ self, signature: str | None, payload: bytes
633
+ ) -> dict[str, Any]:
634
+ if hasattr(self._client, "verify_and_parse_webhook"):
635
+ result = await _maybe_await(
636
+ self._client.verify_and_parse_webhook(
637
+ signature=signature,
638
+ payload=payload,
639
+ secret=self._webhook_secret,
640
+ )
641
+ )
642
+ elif hasattr(self._client, "verify_webhook"):
643
+ result = await _maybe_await(
644
+ self._client.verify_webhook(
645
+ payload=payload,
646
+ signature=signature,
647
+ secret=self._webhook_secret,
648
+ )
649
+ )
650
+ else:
651
+ raise RuntimeError("Aiydan client missing webhook verification method")
652
+ if not isinstance(result, dict):
653
+ raise RuntimeError("Aiydan client returned unexpected webhook payload")
654
+ return result
655
+
656
+ async def list_disputes(
657
+ self, *, status: str | None, limit: int, cursor: str | None
658
+ ) -> tuple[list[DisputeOut], str | None]:
659
+ result = await _maybe_await(
660
+ self._client.list_disputes(status=status, limit=limit, cursor=cursor)
661
+ )
662
+ items, next_cursor = _ensure_list_response(result)
663
+ return [_dispute_to_out(item) for item in items], next_cursor
664
+
665
+ async def get_dispute(self, provider_dispute_id: str) -> DisputeOut:
666
+ result = await _maybe_await(self._client.get_dispute(provider_dispute_id))
667
+ return _dispute_to_out(result)
668
+
669
+ async def submit_dispute_evidence(self, provider_dispute_id: str, evidence: dict) -> DisputeOut:
670
+ result = await _maybe_await(
671
+ self._client.submit_dispute_evidence(provider_dispute_id, evidence)
672
+ )
673
+ return _dispute_to_out(result)
674
+
675
+ async def get_balance_snapshot(self) -> BalanceSnapshotOut:
676
+ result = await _maybe_await(self._client.get_balance_snapshot())
677
+ if isinstance(result, BalanceSnapshotOut):
678
+ return result
679
+ if not isinstance(result, dict):
680
+ raise RuntimeError("Aiydan client returned unexpected balance payload")
681
+ return _balance_snapshot_to_out(result)
682
+
683
+ async def list_payouts(
684
+ self, *, limit: int, cursor: str | None
685
+ ) -> tuple[list[PayoutOut], str | None]:
686
+ result = await _maybe_await(self._client.list_payouts(limit=limit, cursor=cursor))
687
+ items, next_cursor = _ensure_list_response(result)
688
+ return [_payout_to_out(item) for item in items], next_cursor
689
+
690
+ async def get_payout(self, provider_payout_id: str) -> PayoutOut:
691
+ result = await _maybe_await(self._client.get_payout(provider_payout_id))
692
+ return _payout_to_out(result)
693
+
694
+ async def list_refunds(
695
+ self,
696
+ *,
697
+ provider_payment_intent_id: str | None,
698
+ limit: int,
699
+ cursor: str | None,
700
+ ) -> tuple[list[RefundOut], str | None]:
701
+ result = await _maybe_await(
702
+ self._client.list_refunds(
703
+ provider_payment_intent_id=provider_payment_intent_id,
704
+ limit=limit,
705
+ cursor=cursor,
706
+ )
707
+ )
708
+ items, next_cursor = _ensure_list_response(result)
709
+ return [_refund_to_out(item) for item in items], next_cursor
710
+
711
+ async def get_refund(self, provider_refund_id: str) -> RefundOut:
712
+ result = await _maybe_await(self._client.get_refund(provider_refund_id))
713
+ return _refund_to_out(result)
714
+
715
+ async def create_usage_record(self, data: UsageRecordIn) -> UsageRecordOut:
716
+ payload = data.model_dump(exclude_none=True)
717
+ result = await _maybe_await(self._client.create_usage_record(payload))
718
+ return _usage_record_to_out(result)
719
+
720
+ async def list_usage_records(
721
+ self, f: UsageRecordListFilter
722
+ ) -> tuple[list[UsageRecordOut], str | None]:
723
+ payload = f.model_dump(exclude_none=True)
724
+ result = await _maybe_await(self._client.list_usage_records(payload))
725
+ items, next_cursor = _ensure_list_response(result)
726
+ return [_usage_record_to_out(item) for item in items], next_cursor
727
+
728
+ async def get_usage_record(self, usage_record_id: str) -> UsageRecordOut:
729
+ result = await _maybe_await(self._client.get_usage_record(usage_record_id))
730
+ return _usage_record_to_out(result)
731
+
732
+ async def create_setup_intent(self, data: SetupIntentCreateIn) -> SetupIntentOut:
733
+ payload = data.model_dump(exclude_none=True)
734
+ result = await _maybe_await(self._client.create_setup_intent(payload))
735
+ return SetupIntentOut(
736
+ id=_coerce_id(result, "provider_setup_intent_id", "setup_intent_id", "id"),
737
+ provider="aiydan",
738
+ provider_setup_intent_id=_coerce_id(
739
+ result, "provider_setup_intent_id", "setup_intent_id", "id"
740
+ ),
741
+ status=str(result.get("status", "")),
742
+ client_secret=result.get("client_secret"),
743
+ next_action=NextAction(type=(result.get("next_action") or {}).get("type")),
744
+ )
745
+
746
+ async def confirm_setup_intent(self, provider_setup_intent_id: str) -> SetupIntentOut:
747
+ result = await _maybe_await(self._client.confirm_setup_intent(provider_setup_intent_id))
748
+ return SetupIntentOut(
749
+ id=_coerce_id(result, "provider_setup_intent_id", "setup_intent_id", "id"),
750
+ provider="aiydan",
751
+ provider_setup_intent_id=_coerce_id(
752
+ result, "provider_setup_intent_id", "setup_intent_id", "id"
753
+ ),
754
+ status=str(result.get("status", "")),
755
+ client_secret=result.get("client_secret"),
756
+ next_action=NextAction(type=(result.get("next_action") or {}).get("type")),
757
+ )
758
+
759
+ async def get_setup_intent(self, provider_setup_intent_id: str) -> SetupIntentOut:
760
+ result = await _maybe_await(self._client.get_setup_intent(provider_setup_intent_id))
761
+ return SetupIntentOut(
762
+ id=_coerce_id(result, "provider_setup_intent_id", "setup_intent_id", "id"),
763
+ provider="aiydan",
764
+ provider_setup_intent_id=_coerce_id(
765
+ result, "provider_setup_intent_id", "setup_intent_id", "id"
766
+ ),
767
+ status=str(result.get("status", "")),
768
+ client_secret=result.get("client_secret"),
769
+ next_action=NextAction(type=(result.get("next_action") or {}).get("type")),
770
+ )
771
+
772
+ async def resume_intent_after_action(self, provider_intent_id: str) -> IntentOut:
773
+ if hasattr(self._client, "resume_intent_after_action"):
774
+ result = await _maybe_await(self._client.resume_intent_after_action(provider_intent_id))
775
+ else:
776
+ result = await _maybe_await(self._client.get_intent(provider_intent_id))
777
+ return _intent_to_out(result)
778
+
779
+ async def list_customers(
780
+ self, *, provider: str | None, user_id: str | None, limit: int, cursor: str | None
781
+ ) -> tuple[list[CustomerOut], str | None]:
782
+ result = await _maybe_await(
783
+ self._client.list_customers(
784
+ provider=provider,
785
+ user_id=user_id,
786
+ limit=limit,
787
+ cursor=cursor,
788
+ )
789
+ )
790
+ items, next_cursor = _ensure_list_response(result)
791
+ return [_customer_to_out(item) for item in items], next_cursor
792
+
793
+ async def get_customer(self, provider_customer_id: str) -> Optional[CustomerOut]:
794
+ result = await _maybe_await(self._client.get_customer(provider_customer_id))
795
+ if result is None:
796
+ return None
797
+ return _customer_to_out(result)