svc-infra 0.1.589__py3-none-any.whl → 0.1.706__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.

Files changed (260) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/README.md +732 -0
  3. svc_infra/apf_payments/models.py +133 -42
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +871 -0
  6. svc_infra/apf_payments/provider/base.py +30 -9
  7. svc_infra/apf_payments/provider/stripe.py +156 -62
  8. svc_infra/apf_payments/schemas.py +19 -10
  9. svc_infra/apf_payments/service.py +211 -68
  10. svc_infra/apf_payments/settings.py +27 -3
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +15 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +245 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +145 -46
  16. svc_infra/api/fastapi/apf_payments/setup.py +26 -8
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  19. svc_infra/api/fastapi/auth/add.py +27 -14
  20. svc_infra/api/fastapi/auth/gaurd.py +104 -13
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  23. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  25. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  26. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  27. svc_infra/api/fastapi/auth/policy.py +0 -1
  28. svc_infra/api/fastapi/auth/providers.py +3 -1
  29. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  30. svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
  31. svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
  32. svc_infra/api/fastapi/auth/security.py +31 -10
  33. svc_infra/api/fastapi/auth/sender.py +8 -1
  34. svc_infra/api/fastapi/auth/settings.py +2 -0
  35. svc_infra/api/fastapi/auth/state.py +3 -1
  36. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  37. svc_infra/api/fastapi/billing/router.py +73 -0
  38. svc_infra/api/fastapi/billing/setup.py +19 -0
  39. svc_infra/api/fastapi/cache/add.py +9 -5
  40. svc_infra/api/fastapi/db/__init__.py +5 -1
  41. svc_infra/api/fastapi/db/http.py +3 -1
  42. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  43. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  44. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  45. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  46. svc_infra/api/fastapi/db/sql/add.py +71 -26
  47. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  48. svc_infra/api/fastapi/db/sql/health.py +3 -1
  49. svc_infra/api/fastapi/db/sql/session.py +18 -0
  50. svc_infra/api/fastapi/db/sql/users.py +29 -5
  51. svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
  52. svc_infra/api/fastapi/docs/add.py +173 -0
  53. svc_infra/api/fastapi/docs/landing.py +4 -2
  54. svc_infra/api/fastapi/docs/scoped.py +62 -15
  55. svc_infra/api/fastapi/dual/__init__.py +12 -2
  56. svc_infra/api/fastapi/dual/dualize.py +1 -1
  57. svc_infra/api/fastapi/dual/protected.py +126 -4
  58. svc_infra/api/fastapi/dual/public.py +25 -0
  59. svc_infra/api/fastapi/dual/router.py +40 -13
  60. svc_infra/api/fastapi/dx.py +33 -2
  61. svc_infra/api/fastapi/ease.py +10 -2
  62. svc_infra/api/fastapi/http/concurrency.py +2 -1
  63. svc_infra/api/fastapi/http/conditional.py +3 -1
  64. svc_infra/api/fastapi/middleware/debug.py +4 -1
  65. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  66. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  67. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  68. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  69. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  70. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  71. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  72. svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
  73. svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
  74. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  75. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  76. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  77. svc_infra/api/fastapi/openapi/apply.py +5 -3
  78. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  79. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  80. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  81. svc_infra/api/fastapi/openapi/security.py +3 -1
  82. svc_infra/api/fastapi/ops/add.py +75 -0
  83. svc_infra/api/fastapi/pagination.py +47 -20
  84. svc_infra/api/fastapi/routers/__init__.py +43 -15
  85. svc_infra/api/fastapi/routers/ping.py +1 -0
  86. svc_infra/api/fastapi/setup.py +188 -56
  87. svc_infra/api/fastapi/tenancy/add.py +19 -0
  88. svc_infra/api/fastapi/tenancy/context.py +112 -0
  89. svc_infra/api/fastapi/versioned.py +101 -0
  90. svc_infra/app/README.md +5 -5
  91. svc_infra/app/__init__.py +3 -1
  92. svc_infra/app/env.py +69 -1
  93. svc_infra/app/logging/add.py +9 -2
  94. svc_infra/app/logging/formats.py +12 -5
  95. svc_infra/billing/__init__.py +23 -0
  96. svc_infra/billing/async_service.py +147 -0
  97. svc_infra/billing/jobs.py +241 -0
  98. svc_infra/billing/models.py +177 -0
  99. svc_infra/billing/quotas.py +103 -0
  100. svc_infra/billing/schemas.py +36 -0
  101. svc_infra/billing/service.py +123 -0
  102. svc_infra/bundled_docs/README.md +5 -0
  103. svc_infra/bundled_docs/__init__.py +1 -0
  104. svc_infra/bundled_docs/getting-started.md +6 -0
  105. svc_infra/cache/__init__.py +9 -0
  106. svc_infra/cache/add.py +170 -0
  107. svc_infra/cache/backend.py +7 -6
  108. svc_infra/cache/decorators.py +81 -15
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +24 -4
  111. svc_infra/cache/recache.py +26 -14
  112. svc_infra/cache/resources.py +14 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/utils.py +3 -1
  115. svc_infra/cli/__init__.py +52 -8
  116. svc_infra/cli/__main__.py +4 -0
  117. svc_infra/cli/cmds/__init__.py +39 -2
  118. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  120. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  121. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  122. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  123. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  124. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  125. svc_infra/cli/cmds/dx/__init__.py +12 -0
  126. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  127. svc_infra/cli/cmds/health/__init__.py +179 -0
  128. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  129. svc_infra/cli/cmds/help.py +4 -0
  130. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  131. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  132. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  133. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  134. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  135. svc_infra/cli/foundation/runner.py +6 -2
  136. svc_infra/data/add.py +61 -0
  137. svc_infra/data/backup.py +58 -0
  138. svc_infra/data/erasure.py +45 -0
  139. svc_infra/data/fixtures.py +42 -0
  140. svc_infra/data/retention.py +61 -0
  141. svc_infra/db/__init__.py +15 -0
  142. svc_infra/db/crud_schema.py +9 -9
  143. svc_infra/db/inbox.py +67 -0
  144. svc_infra/db/nosql/__init__.py +3 -0
  145. svc_infra/db/nosql/core.py +30 -9
  146. svc_infra/db/nosql/indexes.py +3 -1
  147. svc_infra/db/nosql/management.py +1 -1
  148. svc_infra/db/nosql/mongo/README.md +13 -13
  149. svc_infra/db/nosql/mongo/client.py +19 -2
  150. svc_infra/db/nosql/mongo/settings.py +6 -2
  151. svc_infra/db/nosql/repository.py +35 -15
  152. svc_infra/db/nosql/resource.py +20 -3
  153. svc_infra/db/nosql/scaffold.py +9 -3
  154. svc_infra/db/nosql/service.py +3 -1
  155. svc_infra/db/nosql/types.py +6 -2
  156. svc_infra/db/ops.py +384 -0
  157. svc_infra/db/outbox.py +108 -0
  158. svc_infra/db/sql/apikey.py +37 -9
  159. svc_infra/db/sql/authref.py +9 -3
  160. svc_infra/db/sql/constants.py +12 -8
  161. svc_infra/db/sql/core.py +2 -2
  162. svc_infra/db/sql/management.py +11 -8
  163. svc_infra/db/sql/repository.py +99 -26
  164. svc_infra/db/sql/resource.py +5 -0
  165. svc_infra/db/sql/scaffold.py +6 -2
  166. svc_infra/db/sql/service.py +15 -5
  167. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  168. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  169. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  170. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  171. svc_infra/db/sql/tenant.py +88 -0
  172. svc_infra/db/sql/uniq_hooks.py +9 -3
  173. svc_infra/db/sql/utils.py +138 -51
  174. svc_infra/db/sql/versioning.py +14 -0
  175. svc_infra/deploy/__init__.py +538 -0
  176. svc_infra/documents/__init__.py +100 -0
  177. svc_infra/documents/add.py +264 -0
  178. svc_infra/documents/ease.py +233 -0
  179. svc_infra/documents/models.py +114 -0
  180. svc_infra/documents/storage.py +264 -0
  181. svc_infra/dx/add.py +65 -0
  182. svc_infra/dx/changelog.py +74 -0
  183. svc_infra/dx/checks.py +68 -0
  184. svc_infra/exceptions.py +141 -0
  185. svc_infra/health/__init__.py +864 -0
  186. svc_infra/http/__init__.py +13 -0
  187. svc_infra/http/client.py +105 -0
  188. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  189. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  190. svc_infra/jobs/easy.py +33 -0
  191. svc_infra/jobs/loader.py +50 -0
  192. svc_infra/jobs/queue.py +116 -0
  193. svc_infra/jobs/redis_queue.py +256 -0
  194. svc_infra/jobs/runner.py +79 -0
  195. svc_infra/jobs/scheduler.py +53 -0
  196. svc_infra/jobs/worker.py +40 -0
  197. svc_infra/loaders/__init__.py +186 -0
  198. svc_infra/loaders/base.py +142 -0
  199. svc_infra/loaders/github.py +311 -0
  200. svc_infra/loaders/models.py +147 -0
  201. svc_infra/loaders/url.py +235 -0
  202. svc_infra/logging/__init__.py +374 -0
  203. svc_infra/mcp/svc_infra_mcp.py +91 -33
  204. svc_infra/obs/README.md +2 -0
  205. svc_infra/obs/add.py +65 -9
  206. svc_infra/obs/cloud_dash.py +2 -1
  207. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  208. svc_infra/obs/metrics/__init__.py +52 -0
  209. svc_infra/obs/metrics/asgi.py +13 -7
  210. svc_infra/obs/metrics/http.py +9 -5
  211. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  212. svc_infra/obs/metrics.py +53 -0
  213. svc_infra/obs/settings.py +6 -2
  214. svc_infra/security/add.py +217 -0
  215. svc_infra/security/audit.py +212 -0
  216. svc_infra/security/audit_service.py +74 -0
  217. svc_infra/security/headers.py +52 -0
  218. svc_infra/security/hibp.py +101 -0
  219. svc_infra/security/jwt_rotation.py +105 -0
  220. svc_infra/security/lockout.py +102 -0
  221. svc_infra/security/models.py +287 -0
  222. svc_infra/security/oauth_models.py +73 -0
  223. svc_infra/security/org_invites.py +130 -0
  224. svc_infra/security/passwords.py +79 -0
  225. svc_infra/security/permissions.py +171 -0
  226. svc_infra/security/session.py +98 -0
  227. svc_infra/security/signed_cookies.py +100 -0
  228. svc_infra/storage/__init__.py +93 -0
  229. svc_infra/storage/add.py +253 -0
  230. svc_infra/storage/backends/__init__.py +11 -0
  231. svc_infra/storage/backends/local.py +339 -0
  232. svc_infra/storage/backends/memory.py +216 -0
  233. svc_infra/storage/backends/s3.py +353 -0
  234. svc_infra/storage/base.py +239 -0
  235. svc_infra/storage/easy.py +185 -0
  236. svc_infra/storage/settings.py +195 -0
  237. svc_infra/testing/__init__.py +685 -0
  238. svc_infra/utils.py +7 -3
  239. svc_infra/webhooks/__init__.py +69 -0
  240. svc_infra/webhooks/add.py +339 -0
  241. svc_infra/webhooks/encryption.py +115 -0
  242. svc_infra/webhooks/fastapi.py +39 -0
  243. svc_infra/webhooks/router.py +55 -0
  244. svc_infra/webhooks/service.py +70 -0
  245. svc_infra/webhooks/signing.py +34 -0
  246. svc_infra/websocket/__init__.py +79 -0
  247. svc_infra/websocket/add.py +140 -0
  248. svc_infra/websocket/client.py +282 -0
  249. svc_infra/websocket/config.py +69 -0
  250. svc_infra/websocket/easy.py +76 -0
  251. svc_infra/websocket/exceptions.py +61 -0
  252. svc_infra/websocket/manager.py +344 -0
  253. svc_infra/websocket/models.py +49 -0
  254. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  255. svc_infra-0.1.706.dist-info/METADATA +356 -0
  256. svc_infra-0.1.706.dist-info/RECORD +357 -0
  257. svc_infra-0.1.589.dist-info/METADATA +0 -79
  258. svc_infra-0.1.589.dist-info/RECORD +0 -234
  259. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  260. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -1,11 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Literal, Optional
3
+ from typing import Annotated, Any, Literal, Optional
4
4
 
5
- from pydantic import BaseModel, Field, conint, constr
5
+ from pydantic import BaseModel, Field, StringConstraints
6
6
 
7
- Currency = constr(pattern=r"^[A-Z]{3}$")
8
- AmountMinor = conint(ge=0) # minor units (cents)
7
+ # Type aliases for payment fields using Annotated with proper type hints
8
+ Currency = Annotated[str, StringConstraints(pattern=r"^[A-Z]{3}$")]
9
+ AmountMinor = Annotated[int, Field(ge=0)] # minor units (cents)
9
10
 
10
11
 
11
12
  class CustomerUpsertIn(BaseModel):
@@ -24,10 +25,12 @@ class CustomerOut(BaseModel):
24
25
 
25
26
  class IntentCreateIn(BaseModel):
26
27
  amount: AmountMinor = Field(..., description="Minor units (e.g., cents)")
27
- currency: Currency = Field(..., example="USD")
28
+ currency: Currency = Field(..., json_schema_extra={"example": "USD"})
28
29
  description: Optional[str] = None
29
30
  capture_method: Literal["automatic", "manual"] = "automatic"
30
- payment_method_types: list[str] = Field(default_factory=list) # let provider default
31
+ payment_method_types: list[str] = Field(
32
+ default_factory=list
33
+ ) # let provider default
31
34
 
32
35
 
33
36
  class NextAction(BaseModel):
@@ -133,14 +136,18 @@ class SubscriptionCreateIn(BaseModel):
133
136
  price_provider_id: str
134
137
  quantity: int = 1
135
138
  trial_days: Optional[int] = None
136
- proration_behavior: Literal["create_prorations", "none", "always_invoice"] = "create_prorations"
139
+ proration_behavior: Literal["create_prorations", "none", "always_invoice"] = (
140
+ "create_prorations"
141
+ )
137
142
 
138
143
 
139
144
  class SubscriptionUpdateIn(BaseModel):
140
145
  price_provider_id: Optional[str] = None
141
146
  quantity: Optional[int] = None
142
147
  cancel_at_period_end: Optional[bool] = None
143
- proration_behavior: Literal["create_prorations", "none", "always_invoice"] = "create_prorations"
148
+ proration_behavior: Literal["create_prorations", "none", "always_invoice"] = (
149
+ "create_prorations"
150
+ )
144
151
 
145
152
 
146
153
  class SubscriptionOut(BaseModel):
@@ -187,7 +194,7 @@ class UsageRecordIn(BaseModel):
187
194
  # If provider doesn't use subscription_item, allow provider_price_id as fallback.
188
195
  subscription_item: Optional[str] = None
189
196
  provider_price_id: Optional[str] = None
190
- quantity: conint(ge=0)
197
+ quantity: Annotated[int, Field(ge=0)]
191
198
  timestamp: Optional[int] = None # Unix seconds; provider defaults to "now"
192
199
  action: Optional[Literal["increment", "set"]] = "increment"
193
200
 
@@ -198,7 +205,9 @@ class InvoiceLineItemIn(BaseModel):
198
205
  unit_amount: AmountMinor
199
206
  currency: Currency
200
207
  quantity: Optional[int] = 1
201
- provider_price_id: Optional[str] = None # if linked to a price, unit_amount may be ignored
208
+ provider_price_id: Optional[str] = (
209
+ None # if linked to a price, unit_amount may be ignored
210
+ )
202
211
 
203
212
 
204
213
  class InvoicesListFilter(BaseModel):
@@ -19,6 +19,7 @@ from .models import (
19
19
  PaySetupIntent,
20
20
  PaySubscription,
21
21
  )
22
+ from .provider.base import ProviderAdapter
22
23
  from .provider.registry import get_provider_registry
23
24
  from .schemas import (
24
25
  BalanceSnapshotOut,
@@ -65,15 +66,30 @@ def _default_provider_name() -> str:
65
66
 
66
67
 
67
68
  class PaymentsService:
69
+ """Payments service facade wrapping provider adapters and persisting key rows.
68
70
 
69
- def __init__(self, session: AsyncSession, provider_name: Optional[str] = None):
71
+ NOTE: tenant_id is now required for all persistence operations. This is a breaking
72
+ change; callers must supply a valid tenant scope. (Future: could allow multi-tenant
73
+ mapping via adapter registry.)
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ session: AsyncSession,
79
+ *,
80
+ tenant_id: str,
81
+ provider_name: Optional[str] = None,
82
+ ):
83
+ if not tenant_id:
84
+ raise ValueError("tenant_id is required for PaymentsService")
70
85
  self.session = session
86
+ self.tenant_id = tenant_id
71
87
  self._provider_name = (provider_name or _default_provider_name()).lower()
72
- self._adapter = None # resolved on first use
88
+ self._adapter: ProviderAdapter | None = None # resolved on first use
73
89
 
74
90
  # --- internal helpers -----------------------------------------------------
75
91
 
76
- def _get_adapter(self):
92
+ def _get_adapter(self) -> ProviderAdapter:
77
93
  if self._adapter is not None:
78
94
  return self._adapter
79
95
  reg = get_provider_registry()
@@ -118,6 +134,7 @@ class PaymentsService:
118
134
  # If your PayCustomer model has additional columns (email/name), include them here.
119
135
  self.session.add(
120
136
  PayCustomer(
137
+ tenant_id=self.tenant_id,
121
138
  provider=out.provider,
122
139
  provider_customer_id=out.provider_customer_id,
123
140
  user_id=data.user_id,
@@ -127,11 +144,14 @@ class PaymentsService:
127
144
 
128
145
  # --- Intents --------------------------------------------------------------
129
146
 
130
- async def create_intent(self, user_id: Optional[str], data: IntentCreateIn) -> IntentOut:
147
+ async def create_intent(
148
+ self, user_id: Optional[str], data: IntentCreateIn
149
+ ) -> IntentOut:
131
150
  adapter = self._get_adapter()
132
151
  out = await adapter.create_intent(data, user_id=user_id)
133
152
  self.session.add(
134
153
  PayIntent(
154
+ tenant_id=self.tenant_id,
135
155
  provider=out.provider,
136
156
  provider_intent_id=out.provider_intent_id,
137
157
  user_id=user_id,
@@ -167,15 +187,44 @@ class PaymentsService:
167
187
  async def refund(self, provider_intent_id: str, data: RefundIn) -> IntentOut:
168
188
  adapter = self._get_adapter()
169
189
  out = await adapter.refund(provider_intent_id, data)
190
+ # Create ledger entry if amount present and not already recorded
191
+ pi = await self.session.scalar(
192
+ select(PayIntent).where(PayIntent.provider_intent_id == provider_intent_id)
193
+ )
194
+ if pi:
195
+ amount = int(data.amount) if data.amount is not None else out.amount
196
+ # Guard against duplicates (same provider_ref + kind)
197
+ existing = await self.session.scalar(
198
+ select(LedgerEntry).where(
199
+ LedgerEntry.provider_ref == provider_intent_id,
200
+ LedgerEntry.kind == "refund",
201
+ )
202
+ )
203
+ if amount > 0 and not existing:
204
+ self.session.add(
205
+ LedgerEntry(
206
+ tenant_id=self.tenant_id,
207
+ provider=pi.provider,
208
+ provider_ref=provider_intent_id,
209
+ user_id=pi.user_id,
210
+ amount=+amount,
211
+ currency=out.currency,
212
+ kind="refund",
213
+ status="posted",
214
+ )
215
+ )
170
216
  return out
171
217
 
172
218
  # --- Webhooks -------------------------------------------------------------
173
219
 
174
- async def handle_webhook(self, provider: str, signature: str | None, payload: bytes) -> dict:
220
+ async def handle_webhook(
221
+ self, provider: str, signature: str | None, payload: bytes
222
+ ) -> dict:
175
223
  adapter = self._get_adapter()
176
224
  parsed = await adapter.verify_and_parse_webhook(signature, payload)
177
225
  self.session.add(
178
226
  PayEvent(
227
+ tenant_id=self.tenant_id,
179
228
  provider=provider,
180
229
  provider_event_id=parsed["id"],
181
230
  type=parsed.get("type", ""),
@@ -199,6 +248,7 @@ class PaymentsService:
199
248
  intent.status = "succeeded"
200
249
  self.session.add(
201
250
  LedgerEntry(
251
+ tenant_id=self.tenant_id,
202
252
  provider=intent.provider,
203
253
  provider_ref=provider_intent_id,
204
254
  user_id=intent.user_id,
@@ -217,17 +267,27 @@ class PaymentsService:
217
267
  select(PayIntent).where(PayIntent.provider_intent_id == pi_id)
218
268
  )
219
269
  if intent:
220
- self.session.add(
221
- LedgerEntry(
222
- provider=intent.provider,
223
- provider_ref=charge_obj.get("id"),
224
- user_id=intent.user_id,
225
- amount=+amount,
226
- currency=currency,
227
- kind="capture",
228
- status="posted",
270
+ # Avoid duplicate capture entries
271
+ existing = await self.session.scalar(
272
+ select(LedgerEntry).where(
273
+ LedgerEntry.provider_ref == charge_obj.get("id"),
274
+ LedgerEntry.kind == "capture",
229
275
  )
230
276
  )
277
+ if not existing:
278
+ self.session.add(
279
+ LedgerEntry(
280
+ tenant_id=self.tenant_id,
281
+ provider=intent.provider,
282
+ provider_ref=charge_obj.get("id"),
283
+ user_id=intent.user_id,
284
+ amount=+amount,
285
+ currency=currency,
286
+ kind="capture",
287
+ status="posted",
288
+ )
289
+ )
290
+ intent.captured = True
231
291
 
232
292
  async def _post_refund(self, charge_obj: dict):
233
293
  amount = int(charge_obj.get("amount_refunded") or 0)
@@ -237,22 +297,33 @@ class PaymentsService:
237
297
  select(PayIntent).where(PayIntent.provider_intent_id == pi_id)
238
298
  )
239
299
  if intent and amount > 0:
240
- self.session.add(
241
- LedgerEntry(
242
- provider=intent.provider,
243
- provider_ref=charge_obj.get("id"),
244
- user_id=intent.user_id,
245
- amount=+amount,
246
- currency=currency,
247
- kind="refund",
248
- status="posted",
300
+ existing = await self.session.scalar(
301
+ select(LedgerEntry).where(
302
+ LedgerEntry.provider_ref == charge_obj.get("id"),
303
+ LedgerEntry.kind == "refund",
249
304
  )
250
305
  )
306
+ if not existing:
307
+ self.session.add(
308
+ LedgerEntry(
309
+ tenant_id=self.tenant_id,
310
+ provider=intent.provider,
311
+ provider_ref=charge_obj.get("id"),
312
+ user_id=intent.user_id,
313
+ amount=+amount,
314
+ currency=currency,
315
+ kind="refund",
316
+ status="posted",
317
+ )
318
+ )
251
319
 
252
- async def attach_payment_method(self, data: PaymentMethodAttachIn) -> PaymentMethodOut:
320
+ async def attach_payment_method(
321
+ self, data: PaymentMethodAttachIn
322
+ ) -> PaymentMethodOut:
253
323
  out = await self._get_adapter().attach_payment_method(data)
254
324
  # Upsert locally for quick listing
255
325
  pm = PayPaymentMethod(
326
+ tenant_id=self.tenant_id,
256
327
  provider=out.provider,
257
328
  provider_customer_id=out.provider_customer_id,
258
329
  provider_method_id=out.provider_method_id,
@@ -265,7 +336,9 @@ class PaymentsService:
265
336
  self.session.add(pm)
266
337
  return out
267
338
 
268
- async def list_payment_methods(self, provider_customer_id: str) -> list[PaymentMethodOut]:
339
+ async def list_payment_methods(
340
+ self, provider_customer_id: str
341
+ ) -> list[PaymentMethodOut]:
269
342
  return await self._get_adapter().list_payment_methods(provider_customer_id)
270
343
 
271
344
  async def detach_payment_method(self, provider_method_id: str) -> PaymentMethodOut:
@@ -283,6 +356,7 @@ class PaymentsService:
283
356
  out = await self._get_adapter().create_product(data)
284
357
  self.session.add(
285
358
  PayProduct(
359
+ tenant_id=self.tenant_id,
286
360
  provider=out.provider,
287
361
  provider_product_id=out.provider_product_id,
288
362
  name=out.name,
@@ -295,6 +369,7 @@ class PaymentsService:
295
369
  out = await self._get_adapter().create_price(data)
296
370
  self.session.add(
297
371
  PayPrice(
372
+ tenant_id=self.tenant_id,
298
373
  provider=out.provider,
299
374
  provider_price_id=out.provider_price_id,
300
375
  provider_product_id=out.provider_product_id,
@@ -312,6 +387,7 @@ class PaymentsService:
312
387
  out = await self._get_adapter().create_subscription(data)
313
388
  self.session.add(
314
389
  PaySubscription(
390
+ tenant_id=self.tenant_id,
315
391
  provider=out.provider,
316
392
  provider_subscription_id=out.provider_subscription_id,
317
393
  provider_price_id=out.provider_price_id,
@@ -325,14 +401,18 @@ class PaymentsService:
325
401
  async def update_subscription(
326
402
  self, provider_subscription_id: str, data: SubscriptionUpdateIn
327
403
  ) -> SubscriptionOut:
328
- out = await self._get_adapter().update_subscription(provider_subscription_id, data)
404
+ out = await self._get_adapter().update_subscription(
405
+ provider_subscription_id, data
406
+ )
329
407
  # Optionally reflect status/quantity locally (query + update if exists)
330
408
  return out
331
409
 
332
410
  async def cancel_subscription(
333
411
  self, provider_subscription_id: str, at_period_end: bool = True
334
412
  ) -> SubscriptionOut:
335
- out = await self._get_adapter().cancel_subscription(provider_subscription_id, at_period_end)
413
+ out = await self._get_adapter().cancel_subscription(
414
+ provider_subscription_id, at_period_end
415
+ )
336
416
  return out
337
417
 
338
418
  # --- Invoices ---
@@ -340,6 +420,7 @@ class PaymentsService:
340
420
  out = await self._get_adapter().create_invoice(data)
341
421
  self.session.add(
342
422
  PayInvoice(
423
+ tenant_id=self.tenant_id,
343
424
  provider=out.provider,
344
425
  provider_invoice_id=out.provider_invoice_id,
345
426
  provider_customer_id=out.provider_customer_id,
@@ -376,15 +457,15 @@ class PaymentsService:
376
457
  q = select(
377
458
  func.date_trunc("day", LedgerEntry.ts).label("day"),
378
459
  LedgerEntry.currency,
379
- func.sum(func.case((LedgerEntry.kind == "payment", LedgerEntry.amount), else_=0)).label(
380
- "gross"
381
- ),
382
- func.sum(func.case((LedgerEntry.kind == "refund", LedgerEntry.amount), else_=0)).label(
383
- "refunds"
384
- ),
385
- func.sum(func.case((LedgerEntry.kind == "fee", LedgerEntry.amount), else_=0)).label(
386
- "fees"
387
- ),
460
+ func.sum(
461
+ func.case((LedgerEntry.kind == "payment", LedgerEntry.amount), else_=0)
462
+ ).label("gross"),
463
+ func.sum(
464
+ func.case((LedgerEntry.kind == "refund", LedgerEntry.amount), else_=0)
465
+ ).label("refunds"),
466
+ func.sum(
467
+ func.case((LedgerEntry.kind == "fee", LedgerEntry.amount), else_=0)
468
+ ).label("fees"),
388
469
  func.count().label("count"),
389
470
  )
390
471
  if date_from:
@@ -397,9 +478,9 @@ class PaymentsService:
397
478
  q = q.where(LedgerEntry.ts <= datetime.fromisoformat(date_to))
398
479
  except Exception:
399
480
  pass
400
- q = q.group_by(func.date_trunc("day", LedgerEntry.ts), LedgerEntry.currency).order_by(
401
- func.date_trunc("day", LedgerEntry.ts).desc()
402
- )
481
+ q = q.group_by(
482
+ func.date_trunc("day", LedgerEntry.ts), LedgerEntry.currency
483
+ ).order_by(func.date_trunc("day", LedgerEntry.ts).desc())
403
484
 
404
485
  rows = (await self.session.execute(q)).all()
405
486
  out: list[StatementRow] = []
@@ -421,9 +502,12 @@ class PaymentsService:
421
502
  )
422
503
  return out
423
504
 
424
- async def capture_intent(self, provider_intent_id: str, data: CaptureIn) -> IntentOut:
505
+ async def capture_intent(
506
+ self, provider_intent_id: str, data: CaptureIn
507
+ ) -> IntentOut:
425
508
  out = await self._get_adapter().capture_intent(
426
- provider_intent_id, amount=int(data.amount) if data.amount is not None else None
509
+ provider_intent_id,
510
+ amount=int(data.amount) if data.amount is not None else None,
427
511
  )
428
512
  pi = await self.session.scalar(
429
513
  select(PayIntent).where(PayIntent.provider_intent_id == provider_intent_id)
@@ -432,9 +516,32 @@ class PaymentsService:
432
516
  pi.status = out.status
433
517
  if out.status in ("succeeded", "requires_capture"): # Stripe specifics vary
434
518
  pi.captured = True if out.status == "succeeded" else pi.captured
519
+ # Add capture ledger entry if succeeded and not already posted
520
+ if out.status == "succeeded":
521
+ existing = await self.session.scalar(
522
+ select(LedgerEntry).where(
523
+ LedgerEntry.provider_ref == provider_intent_id,
524
+ LedgerEntry.kind == "capture",
525
+ )
526
+ )
527
+ if not existing:
528
+ self.session.add(
529
+ LedgerEntry(
530
+ tenant_id=self.tenant_id,
531
+ provider=pi.provider,
532
+ provider_ref=provider_intent_id,
533
+ user_id=pi.user_id,
534
+ amount=+out.amount,
535
+ currency=out.currency,
536
+ kind="capture",
537
+ status="posted",
538
+ )
539
+ )
435
540
  return out
436
541
 
437
- async def list_intents(self, f: IntentListFilter) -> tuple[list[IntentOut], str | None]:
542
+ async def list_intents(
543
+ self, f: IntentListFilter
544
+ ) -> tuple[list[IntentOut], str | None]:
438
545
  return await self._get_adapter().list_intents(
439
546
  customer_provider_id=f.customer_provider_id,
440
547
  status=f.status,
@@ -446,9 +553,13 @@ class PaymentsService:
446
553
  async def add_invoice_line_item(
447
554
  self, provider_invoice_id: str, data: InvoiceLineItemIn
448
555
  ) -> InvoiceOut:
449
- return await self._get_adapter().add_invoice_line_item(provider_invoice_id, data)
556
+ return await self._get_adapter().add_invoice_line_item(
557
+ provider_invoice_id, data
558
+ )
450
559
 
451
- async def list_invoices(self, f: InvoicesListFilter) -> tuple[list[InvoiceOut], str | None]:
560
+ async def list_invoices(
561
+ self, f: InvoicesListFilter
562
+ ) -> tuple[list[InvoiceOut], str | None]:
452
563
  return await self._get_adapter().list_invoices(
453
564
  customer_provider_id=f.customer_provider_id,
454
565
  status=f.status,
@@ -475,6 +586,7 @@ class PaymentsService:
475
586
  out = await self._get_adapter().create_setup_intent(data)
476
587
  self.session.add(
477
588
  PaySetupIntent(
589
+ tenant_id=self.tenant_id,
478
590
  provider=out.provider,
479
591
  provider_setup_intent_id=out.provider_setup_intent_id,
480
592
  user_id=None,
@@ -484,7 +596,9 @@ class PaymentsService:
484
596
  )
485
597
  return out
486
598
 
487
- async def confirm_setup_intent(self, provider_setup_intent_id: str) -> SetupIntentOut:
599
+ async def confirm_setup_intent(
600
+ self, provider_setup_intent_id: str
601
+ ) -> SetupIntentOut:
488
602
  out = await self._get_adapter().confirm_setup_intent(provider_setup_intent_id)
489
603
  row = await self.session.scalar(
490
604
  select(PaySetupIntent).where(
@@ -510,6 +624,7 @@ class PaymentsService:
510
624
  else:
511
625
  self.session.add(
512
626
  PaySetupIntent(
627
+ tenant_id=self.tenant_id,
513
628
  provider=out.provider,
514
629
  provider_setup_intent_id=out.provider_setup_intent_id,
515
630
  user_id=None,
@@ -534,13 +649,17 @@ class PaymentsService:
534
649
  async def list_disputes(
535
650
  self, *, status: Optional[str], limit: int, cursor: Optional[str]
536
651
  ) -> tuple[list[DisputeOut], Optional[str]]:
537
- return await self._get_adapter().list_disputes(status=status, limit=limit, cursor=cursor)
652
+ return await self._get_adapter().list_disputes(
653
+ status=status, limit=limit, cursor=cursor
654
+ )
538
655
 
539
656
  async def get_dispute(self, provider_dispute_id: str) -> DisputeOut:
540
657
  out = await self._get_adapter().get_dispute(provider_dispute_id)
541
658
  # Upsert locally
542
659
  row = await self.session.scalar(
543
- select(PayDispute).where(PayDispute.provider_dispute_id == provider_dispute_id)
660
+ select(PayDispute).where(
661
+ PayDispute.provider_dispute_id == provider_dispute_id
662
+ )
544
663
  )
545
664
  if row:
546
665
  row.status = out.status
@@ -549,6 +668,7 @@ class PaymentsService:
549
668
  else:
550
669
  self.session.add(
551
670
  PayDispute(
671
+ tenant_id=self.tenant_id,
552
672
  provider=out.provider,
553
673
  provider_dispute_id=out.provider_dispute_id,
554
674
  provider_charge_id=None, # set if adapter returns it
@@ -560,11 +680,17 @@ class PaymentsService:
560
680
  )
561
681
  return out
562
682
 
563
- async def submit_dispute_evidence(self, provider_dispute_id: str, evidence: dict) -> DisputeOut:
564
- out = await self._get_adapter().submit_dispute_evidence(provider_dispute_id, evidence)
683
+ async def submit_dispute_evidence(
684
+ self, provider_dispute_id: str, evidence: dict
685
+ ) -> DisputeOut:
686
+ out = await self._get_adapter().submit_dispute_evidence(
687
+ provider_dispute_id, evidence
688
+ )
565
689
  # reflect status
566
690
  row = await self.session.scalar(
567
- select(PayDispute).where(PayDispute.provider_dispute_id == provider_dispute_id)
691
+ select(PayDispute).where(
692
+ PayDispute.provider_dispute_id == provider_dispute_id
693
+ )
568
694
  )
569
695
  if row:
570
696
  row.status = out.status
@@ -594,6 +720,7 @@ class PaymentsService:
594
720
  else:
595
721
  self.session.add(
596
722
  PayPayout(
723
+ tenant_id=self.tenant_id,
597
724
  provider=out.provider,
598
725
  provider_payout_id=out.provider_payout_id,
599
726
  amount=out.amount,
@@ -633,11 +760,16 @@ class PaymentsService:
633
760
  return len(rows)
634
761
 
635
762
  # ---- Customers ----
636
- async def list_customers(self, f: CustomersListFilter) -> tuple[list[CustomerOut], str | None]:
763
+ async def list_customers(
764
+ self, f: CustomersListFilter
765
+ ) -> tuple[list[CustomerOut], str | None]:
637
766
  adapter = self._get_adapter()
638
767
  try:
639
768
  return await adapter.list_customers(
640
- provider=f.provider, user_id=f.user_id, limit=f.limit or 50, cursor=f.cursor
769
+ provider=f.provider,
770
+ user_id=f.user_id,
771
+ limit=f.limit or 50,
772
+ cursor=f.cursor,
641
773
  )
642
774
  except NotImplementedError:
643
775
  # Fallback to local DB listing
@@ -673,15 +805,17 @@ class PaymentsService:
673
805
  raise RuntimeError("Customer not found")
674
806
  # upsert locally
675
807
  row = await self.session.scalar(
676
- select(PayCustomer).where(PayCustomer.provider_customer_id == provider_customer_id)
808
+ select(PayCustomer).where(
809
+ PayCustomer.provider_customer_id == provider_customer_id
810
+ )
677
811
  )
678
812
  if not row:
679
813
  self.session.add(
680
814
  PayCustomer(
815
+ tenant_id=self.tenant_id,
681
816
  provider=out.provider,
682
817
  provider_customer_id=out.provider_customer_id,
683
818
  user_id=None,
684
- tenant_id="",
685
819
  )
686
820
  )
687
821
  return out
@@ -693,13 +827,19 @@ class PaymentsService:
693
827
  async def list_products(
694
828
  self, *, active: bool | None, limit: int, cursor: str | None
695
829
  ) -> tuple[list[ProductOut], str | None]:
696
- return await self._get_adapter().list_products(active=active, limit=limit, cursor=cursor)
830
+ return await self._get_adapter().list_products(
831
+ active=active, limit=limit, cursor=cursor
832
+ )
697
833
 
698
- async def update_product(self, provider_product_id: str, data: ProductUpdateIn) -> ProductOut:
834
+ async def update_product(
835
+ self, provider_product_id: str, data: ProductUpdateIn
836
+ ) -> ProductOut:
699
837
  out = await self._get_adapter().update_product(provider_product_id, data)
700
838
  # reflect DB
701
839
  row = await self.session.scalar(
702
- select(PayProduct).where(PayProduct.provider_product_id == provider_product_id)
840
+ select(PayProduct).where(
841
+ PayProduct.provider_product_id == provider_product_id
842
+ )
703
843
  )
704
844
  if row:
705
845
  if data.name is not None:
@@ -720,10 +860,15 @@ class PaymentsService:
720
860
  cursor: str | None,
721
861
  ) -> tuple[list[PriceOut], str | None]:
722
862
  return await self._get_adapter().list_prices(
723
- provider_product_id=provider_product_id, active=active, limit=limit, cursor=cursor
863
+ provider_product_id=provider_product_id,
864
+ active=active,
865
+ limit=limit,
866
+ cursor=cursor,
724
867
  )
725
868
 
726
- async def update_price(self, provider_price_id: str, data: PriceUpdateIn) -> PriceOut:
869
+ async def update_price(
870
+ self, provider_price_id: str, data: PriceUpdateIn
871
+ ) -> PriceOut:
727
872
  out = await self._get_adapter().update_price(provider_price_id, data)
728
873
  row = await self.session.scalar(
729
874
  select(PayPrice).where(PayPrice.provider_price_id == provider_price_id)
@@ -745,7 +890,10 @@ class PaymentsService:
745
890
  cursor: str | None,
746
891
  ) -> tuple[list[SubscriptionOut], str | None]:
747
892
  return await self._get_adapter().list_subscriptions(
748
- customer_provider_id=customer_provider_id, status=status, limit=limit, cursor=cursor
893
+ customer_provider_id=customer_provider_id,
894
+ status=status,
895
+ limit=limit,
896
+ cursor=cursor,
749
897
  )
750
898
 
751
899
  # ---- Payment Methods (get/update) ----
@@ -775,7 +923,9 @@ class PaymentsService:
775
923
  self, *, provider_payment_intent_id: str | None, limit: int, cursor: str | None
776
924
  ) -> tuple[list[RefundOut], str | None]:
777
925
  return await self._get_adapter().list_refunds(
778
- provider_payment_intent_id=provider_payment_intent_id, limit=limit, cursor=cursor
926
+ provider_payment_intent_id=provider_payment_intent_id,
927
+ limit=limit,
928
+ cursor=cursor,
779
929
  )
780
930
 
781
931
  async def get_refund(self, provider_refund_id: str) -> RefundOut:
@@ -797,10 +947,3 @@ class PaymentsService:
797
947
 
798
948
  async def get_usage_record(self, usage_record_id: str) -> UsageRecordOut:
799
949
  return await self._get_adapter().get_usage_record(usage_record_id)
800
-
801
- async def delete_invoice_line_item(
802
- self, provider_invoice_id: str, provider_line_item_id: str
803
- ) -> InvoiceOut:
804
- return await self._get_adapter().delete_invoice_line_item(
805
- provider_invoice_id, provider_line_item_id
806
- )
@@ -7,7 +7,18 @@ from pydantic import BaseModel, SecretStr
7
7
 
8
8
  STRIPE_KEY = os.getenv("STRIPE_SECRET") or os.getenv("STRIPE_API_KEY")
9
9
  STRIPE_WH = os.getenv("STRIPE_WH_SECRET")
10
- PROVIDER = (os.getenv("APF_PAYMENTS_PROVIDER") or os.getenv("PAYMENTS_PROVIDER", "stripe")).lower()
10
+ PROVIDER = (
11
+ os.getenv("APF_PAYMENTS_PROVIDER")
12
+ or os.getenv("PAYMENTS_PROVIDER", "stripe")
13
+ or "stripe"
14
+ ).lower()
15
+
16
+ AIYDAN_KEY = os.getenv("AIYDAN_API_KEY")
17
+ AIYDAN_CLIENT_KEY = os.getenv("AIYDAN_CLIENT_KEY")
18
+ AIYDAN_MERCHANT = os.getenv("AIYDAN_MERCHANT_ACCOUNT")
19
+ AIYDAN_HMAC = os.getenv("AIYDAN_HMAC_KEY")
20
+ AIYDAN_BASE_URL = os.getenv("AIYDAN_BASE_URL")
21
+ AIYDAN_WH = os.getenv("AIYDAN_WH_SECRET")
11
22
 
12
23
 
13
24
  class StripeConfig(BaseModel):
@@ -15,11 +26,13 @@ class StripeConfig(BaseModel):
15
26
  webhook_secret: Optional[SecretStr] = None
16
27
 
17
28
 
18
- class AdyenConfig(BaseModel):
29
+ class AiydanConfig(BaseModel):
19
30
  api_key: SecretStr
20
31
  client_key: Optional[SecretStr] = None
21
32
  merchant_account: Optional[str] = None
22
33
  hmac_key: Optional[SecretStr] = None
34
+ base_url: Optional[str] = None
35
+ webhook_secret: Optional[SecretStr] = None
23
36
 
24
37
 
25
38
  class PaymentsSettings(BaseModel):
@@ -34,7 +47,18 @@ class PaymentsSettings(BaseModel):
34
47
  if STRIPE_KEY
35
48
  else None
36
49
  )
37
- adyen: Optional[AdyenConfig] = None
50
+ aiydan: Optional[AiydanConfig] = (
51
+ AiydanConfig(
52
+ api_key=SecretStr(AIYDAN_KEY),
53
+ client_key=SecretStr(AIYDAN_CLIENT_KEY) if AIYDAN_CLIENT_KEY else None,
54
+ merchant_account=AIYDAN_MERCHANT,
55
+ hmac_key=SecretStr(AIYDAN_HMAC) if AIYDAN_HMAC else None,
56
+ base_url=AIYDAN_BASE_URL,
57
+ webhook_secret=SecretStr(AIYDAN_WH) if AIYDAN_WH else None,
58
+ )
59
+ if AIYDAN_KEY
60
+ else None
61
+ )
38
62
 
39
63
 
40
64
  _SETTINGS: Optional[PaymentsSettings] = None