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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/alembic.py +11 -0
  3. svc_infra/apf_payments/models.py +339 -0
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +797 -0
  6. svc_infra/apf_payments/provider/base.py +270 -0
  7. svc_infra/apf_payments/provider/registry.py +31 -0
  8. svc_infra/apf_payments/provider/stripe.py +873 -0
  9. svc_infra/apf_payments/schemas.py +333 -0
  10. svc_infra/apf_payments/service.py +892 -0
  11. svc_infra/apf_payments/settings.py +67 -0
  12. svc_infra/api/fastapi/__init__.py +6 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +231 -0
  15. svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
  16. svc_infra/api/fastapi/apf_payments/router.py +1082 -0
  17. svc_infra/api/fastapi/apf_payments/setup.py +73 -0
  18. svc_infra/api/fastapi/auth/add.py +15 -6
  19. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  20. svc_infra/api/fastapi/auth/mfa/router.py +18 -9
  21. svc_infra/api/fastapi/auth/routers/account.py +3 -2
  22. svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
  23. svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
  24. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  25. svc_infra/api/fastapi/auth/security.py +3 -1
  26. svc_infra/api/fastapi/auth/settings.py +2 -0
  27. svc_infra/api/fastapi/auth/state.py +1 -1
  28. svc_infra/api/fastapi/billing/router.py +64 -0
  29. svc_infra/api/fastapi/billing/setup.py +19 -0
  30. svc_infra/api/fastapi/cache/add.py +9 -5
  31. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  32. svc_infra/api/fastapi/db/sql/add.py +40 -18
  33. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  34. svc_infra/api/fastapi/db/sql/session.py +16 -0
  35. svc_infra/api/fastapi/db/sql/users.py +14 -2
  36. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  37. svc_infra/api/fastapi/docs/add.py +160 -0
  38. svc_infra/api/fastapi/docs/landing.py +1 -1
  39. svc_infra/api/fastapi/docs/scoped.py +254 -0
  40. svc_infra/api/fastapi/dual/dualize.py +38 -33
  41. svc_infra/api/fastapi/dual/router.py +48 -1
  42. svc_infra/api/fastapi/dx.py +3 -3
  43. svc_infra/api/fastapi/http/__init__.py +0 -0
  44. svc_infra/api/fastapi/http/concurrency.py +14 -0
  45. svc_infra/api/fastapi/http/conditional.py +33 -0
  46. svc_infra/api/fastapi/http/deprecation.py +21 -0
  47. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  48. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  49. svc_infra/api/fastapi/middleware/idempotency.py +116 -0
  50. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  51. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  52. svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
  53. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  54. svc_infra/api/fastapi/middleware/request_id.py +23 -0
  55. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  56. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  57. svc_infra/api/fastapi/openapi/mutators.py +768 -55
  58. svc_infra/api/fastapi/ops/add.py +73 -0
  59. svc_infra/api/fastapi/pagination.py +363 -0
  60. svc_infra/api/fastapi/paths/auth.py +14 -14
  61. svc_infra/api/fastapi/paths/prefix.py +0 -1
  62. svc_infra/api/fastapi/paths/user.py +1 -1
  63. svc_infra/api/fastapi/routers/ping.py +1 -0
  64. svc_infra/api/fastapi/setup.py +48 -15
  65. svc_infra/api/fastapi/tenancy/add.py +19 -0
  66. svc_infra/api/fastapi/tenancy/context.py +112 -0
  67. svc_infra/api/fastapi/versioned.py +101 -0
  68. svc_infra/app/README.md +5 -5
  69. svc_infra/billing/__init__.py +23 -0
  70. svc_infra/billing/async_service.py +147 -0
  71. svc_infra/billing/jobs.py +230 -0
  72. svc_infra/billing/models.py +131 -0
  73. svc_infra/billing/quotas.py +101 -0
  74. svc_infra/billing/schemas.py +33 -0
  75. svc_infra/billing/service.py +115 -0
  76. svc_infra/bundled_docs/README.md +5 -0
  77. svc_infra/bundled_docs/__init__.py +1 -0
  78. svc_infra/bundled_docs/getting-started.md +6 -0
  79. svc_infra/cache/__init__.py +4 -0
  80. svc_infra/cache/add.py +158 -0
  81. svc_infra/cache/backend.py +5 -2
  82. svc_infra/cache/decorators.py +19 -1
  83. svc_infra/cache/keys.py +24 -4
  84. svc_infra/cli/__init__.py +32 -8
  85. svc_infra/cli/__main__.py +4 -0
  86. svc_infra/cli/cmds/__init__.py +10 -0
  87. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  88. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  89. svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
  90. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  91. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
  92. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  93. svc_infra/cli/cmds/dx/__init__.py +12 -0
  94. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  95. svc_infra/cli/cmds/help.py +4 -0
  96. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  97. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  98. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  99. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  100. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  101. svc_infra/data/add.py +61 -0
  102. svc_infra/data/backup.py +53 -0
  103. svc_infra/data/erasure.py +45 -0
  104. svc_infra/data/fixtures.py +40 -0
  105. svc_infra/data/retention.py +55 -0
  106. svc_infra/db/inbox.py +67 -0
  107. svc_infra/db/nosql/mongo/README.md +13 -13
  108. svc_infra/db/outbox.py +104 -0
  109. svc_infra/db/sql/apikey.py +1 -1
  110. svc_infra/db/sql/authref.py +61 -0
  111. svc_infra/db/sql/core.py +2 -2
  112. svc_infra/db/sql/repository.py +52 -12
  113. svc_infra/db/sql/resource.py +5 -0
  114. svc_infra/db/sql/scaffold.py +16 -4
  115. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  116. svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
  117. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
  118. svc_infra/db/sql/tenant.py +79 -0
  119. svc_infra/db/sql/utils.py +18 -4
  120. svc_infra/db/sql/versioning.py +14 -0
  121. svc_infra/docs/acceptance-matrix.md +71 -0
  122. svc_infra/docs/acceptance.md +44 -0
  123. svc_infra/docs/admin.md +425 -0
  124. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  125. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  126. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  127. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  128. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  129. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  130. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  131. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  132. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  133. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  134. svc_infra/docs/api.md +59 -0
  135. svc_infra/docs/auth.md +11 -0
  136. svc_infra/docs/billing.md +190 -0
  137. svc_infra/docs/cache.md +76 -0
  138. svc_infra/docs/cli.md +74 -0
  139. svc_infra/docs/contributing.md +34 -0
  140. svc_infra/docs/data-lifecycle.md +52 -0
  141. svc_infra/docs/database.md +14 -0
  142. svc_infra/docs/docs-and-sdks.md +62 -0
  143. svc_infra/docs/environment.md +114 -0
  144. svc_infra/docs/getting-started.md +63 -0
  145. svc_infra/docs/idempotency.md +111 -0
  146. svc_infra/docs/jobs.md +67 -0
  147. svc_infra/docs/observability.md +16 -0
  148. svc_infra/docs/ops.md +37 -0
  149. svc_infra/docs/rate-limiting.md +125 -0
  150. svc_infra/docs/repo-review.md +48 -0
  151. svc_infra/docs/security.md +176 -0
  152. svc_infra/docs/tenancy.md +35 -0
  153. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  154. svc_infra/docs/versioned-integrations.md +146 -0
  155. svc_infra/docs/webhooks.md +112 -0
  156. svc_infra/dx/add.py +63 -0
  157. svc_infra/dx/changelog.py +74 -0
  158. svc_infra/dx/checks.py +67 -0
  159. svc_infra/http/__init__.py +13 -0
  160. svc_infra/http/client.py +72 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  162. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  163. svc_infra/jobs/easy.py +32 -0
  164. svc_infra/jobs/loader.py +45 -0
  165. svc_infra/jobs/queue.py +81 -0
  166. svc_infra/jobs/redis_queue.py +191 -0
  167. svc_infra/jobs/runner.py +75 -0
  168. svc_infra/jobs/scheduler.py +41 -0
  169. svc_infra/jobs/worker.py +40 -0
  170. svc_infra/mcp/svc_infra_mcp.py +85 -28
  171. svc_infra/obs/README.md +2 -0
  172. svc_infra/obs/add.py +54 -7
  173. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  174. svc_infra/obs/metrics/__init__.py +53 -0
  175. svc_infra/obs/metrics.py +52 -0
  176. svc_infra/security/add.py +201 -0
  177. svc_infra/security/audit.py +130 -0
  178. svc_infra/security/audit_service.py +73 -0
  179. svc_infra/security/headers.py +52 -0
  180. svc_infra/security/hibp.py +95 -0
  181. svc_infra/security/jwt_rotation.py +53 -0
  182. svc_infra/security/lockout.py +96 -0
  183. svc_infra/security/models.py +255 -0
  184. svc_infra/security/org_invites.py +128 -0
  185. svc_infra/security/passwords.py +77 -0
  186. svc_infra/security/permissions.py +149 -0
  187. svc_infra/security/session.py +98 -0
  188. svc_infra/security/signed_cookies.py +80 -0
  189. svc_infra/webhooks/__init__.py +16 -0
  190. svc_infra/webhooks/add.py +322 -0
  191. svc_infra/webhooks/fastapi.py +37 -0
  192. svc_infra/webhooks/router.py +55 -0
  193. svc_infra/webhooks/service.py +67 -0
  194. svc_infra/webhooks/signing.py +30 -0
  195. svc_infra-0.1.654.dist-info/METADATA +154 -0
  196. svc_infra-0.1.654.dist-info/RECORD +352 -0
  197. svc_infra/api/fastapi/deps.py +0 -3
  198. svc_infra-0.1.506.dist-info/METADATA +0 -78
  199. svc_infra-0.1.506.dist-info/RECORD +0 -213
  200. /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
  201. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  202. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,892 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from sqlalchemy import select
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from .models import (
9
+ LedgerEntry,
10
+ PayCustomer,
11
+ PayDispute,
12
+ PayEvent,
13
+ PayIntent,
14
+ PayInvoice,
15
+ PayPaymentMethod,
16
+ PayPayout,
17
+ PayPrice,
18
+ PayProduct,
19
+ PaySetupIntent,
20
+ PaySubscription,
21
+ )
22
+ from .provider.registry import get_provider_registry
23
+ from .schemas import (
24
+ BalanceSnapshotOut,
25
+ CaptureIn,
26
+ CustomerOut,
27
+ CustomersListFilter,
28
+ CustomerUpsertIn,
29
+ DisputeOut,
30
+ IntentCreateIn,
31
+ IntentListFilter,
32
+ IntentOut,
33
+ InvoiceCreateIn,
34
+ InvoiceLineItemIn,
35
+ InvoiceLineItemOut,
36
+ InvoiceOut,
37
+ InvoicesListFilter,
38
+ PaymentMethodAttachIn,
39
+ PaymentMethodOut,
40
+ PaymentMethodUpdateIn,
41
+ PayoutOut,
42
+ PriceCreateIn,
43
+ PriceOut,
44
+ PriceUpdateIn,
45
+ ProductCreateIn,
46
+ ProductOut,
47
+ ProductUpdateIn,
48
+ RefundIn,
49
+ RefundOut,
50
+ SetupIntentCreateIn,
51
+ SetupIntentOut,
52
+ StatementRow,
53
+ SubscriptionCreateIn,
54
+ SubscriptionOut,
55
+ SubscriptionUpdateIn,
56
+ UsageRecordIn,
57
+ UsageRecordListFilter,
58
+ UsageRecordOut,
59
+ )
60
+ from .settings import get_payments_settings
61
+
62
+
63
+ def _default_provider_name() -> str:
64
+ return get_payments_settings().default_provider
65
+
66
+
67
+ class PaymentsService:
68
+ """Payments service facade wrapping provider adapters and persisting key rows.
69
+
70
+ NOTE: tenant_id is now required for all persistence operations. This is a breaking
71
+ change; callers must supply a valid tenant scope. (Future: could allow multi-tenant
72
+ mapping via adapter registry.)
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ session: AsyncSession,
78
+ *,
79
+ tenant_id: str,
80
+ provider_name: Optional[str] = None,
81
+ ):
82
+ if not tenant_id:
83
+ raise ValueError("tenant_id is required for PaymentsService")
84
+ self.session = session
85
+ self.tenant_id = tenant_id
86
+ self._provider_name = (provider_name or _default_provider_name()).lower()
87
+ self._adapter = None # resolved on first use
88
+
89
+ # --- internal helpers -----------------------------------------------------
90
+
91
+ def _get_adapter(self):
92
+ if self._adapter is not None:
93
+ return self._adapter
94
+ reg = get_provider_registry()
95
+ # Try to fetch the named adapter; if missing, raise a helpful error
96
+ try:
97
+ self._adapter = reg.get(self._provider_name)
98
+ except Exception as e:
99
+ raise RuntimeError(
100
+ f"No payments adapter registered for '{self._provider_name}'. "
101
+ "Install and register a provider (e.g., `stripe`) OR pass a custom adapter via "
102
+ "`add_payments(app, adapters=[...])`. If you only need DB endpoints (like "
103
+ "`/payments/transactions`), this error will not occur unless you call a provider API."
104
+ ) from e
105
+ return self._adapter
106
+
107
+ # --- internal event dispatcher (shared by webhook + replay) ---------------
108
+ async def _dispatch_event(self, provider: str, parsed: dict) -> None:
109
+ typ = parsed.get("type", "")
110
+ obj = parsed.get("data") or {}
111
+
112
+ if provider == "stripe":
113
+ if typ == "payment_intent.succeeded":
114
+ await self._post_sale(obj)
115
+ elif typ == "charge.refunded":
116
+ await self._post_refund(obj)
117
+ elif typ == "charge.captured":
118
+ await self._post_capture(obj)
119
+
120
+ # --- Customers ------------------------------------------------------------
121
+
122
+ async def ensure_customer(self, data: CustomerUpsertIn) -> CustomerOut:
123
+ adapter = self._get_adapter()
124
+ out = await adapter.ensure_customer(data)
125
+ # upsert local row
126
+ existing = await self.session.scalar(
127
+ select(PayCustomer).where(
128
+ PayCustomer.provider == out.provider,
129
+ PayCustomer.provider_customer_id == out.provider_customer_id,
130
+ )
131
+ )
132
+ if not existing:
133
+ # If your PayCustomer model has additional columns (email/name), include them here.
134
+ self.session.add(
135
+ PayCustomer(
136
+ tenant_id=self.tenant_id,
137
+ provider=out.provider,
138
+ provider_customer_id=out.provider_customer_id,
139
+ user_id=data.user_id,
140
+ )
141
+ )
142
+ return out
143
+
144
+ # --- Intents --------------------------------------------------------------
145
+
146
+ async def create_intent(self, user_id: Optional[str], data: IntentCreateIn) -> IntentOut:
147
+ adapter = self._get_adapter()
148
+ out = await adapter.create_intent(data, user_id=user_id)
149
+ self.session.add(
150
+ PayIntent(
151
+ tenant_id=self.tenant_id,
152
+ provider=out.provider,
153
+ provider_intent_id=out.provider_intent_id,
154
+ user_id=user_id,
155
+ amount=out.amount,
156
+ currency=out.currency,
157
+ status=out.status,
158
+ client_secret=out.client_secret,
159
+ )
160
+ )
161
+ return out
162
+
163
+ async def confirm_intent(self, provider_intent_id: str) -> IntentOut:
164
+ adapter = self._get_adapter()
165
+ out = await adapter.confirm_intent(provider_intent_id)
166
+ pi = await self.session.scalar(
167
+ select(PayIntent).where(PayIntent.provider_intent_id == provider_intent_id)
168
+ )
169
+ if pi:
170
+ pi.status = out.status
171
+ pi.client_secret = out.client_secret or pi.client_secret
172
+ return out
173
+
174
+ async def cancel_intent(self, provider_intent_id: str) -> IntentOut:
175
+ adapter = self._get_adapter()
176
+ out = await adapter.cancel_intent(provider_intent_id)
177
+ pi = await self.session.scalar(
178
+ select(PayIntent).where(PayIntent.provider_intent_id == provider_intent_id)
179
+ )
180
+ if pi:
181
+ pi.status = out.status
182
+ return out
183
+
184
+ async def refund(self, provider_intent_id: str, data: RefundIn) -> IntentOut:
185
+ adapter = self._get_adapter()
186
+ out = await adapter.refund(provider_intent_id, data)
187
+ # Create ledger entry if amount present and not already recorded
188
+ pi = await self.session.scalar(
189
+ select(PayIntent).where(PayIntent.provider_intent_id == provider_intent_id)
190
+ )
191
+ if pi:
192
+ amount = int(data.amount) if data.amount is not None else out.amount
193
+ # Guard against duplicates (same provider_ref + kind)
194
+ existing = await self.session.scalar(
195
+ select(LedgerEntry).where(
196
+ LedgerEntry.provider_ref == provider_intent_id,
197
+ LedgerEntry.kind == "refund",
198
+ )
199
+ )
200
+ if amount > 0 and not existing:
201
+ self.session.add(
202
+ LedgerEntry(
203
+ tenant_id=self.tenant_id,
204
+ provider=pi.provider,
205
+ provider_ref=provider_intent_id,
206
+ user_id=pi.user_id,
207
+ amount=+amount,
208
+ currency=out.currency,
209
+ kind="refund",
210
+ status="posted",
211
+ )
212
+ )
213
+ return out
214
+
215
+ # --- Webhooks -------------------------------------------------------------
216
+
217
+ async def handle_webhook(self, provider: str, signature: str | None, payload: bytes) -> dict:
218
+ adapter = self._get_adapter()
219
+ parsed = await adapter.verify_and_parse_webhook(signature, payload)
220
+ self.session.add(
221
+ PayEvent(
222
+ tenant_id=self.tenant_id,
223
+ provider=provider,
224
+ provider_event_id=parsed["id"],
225
+ type=parsed.get("type", ""),
226
+ payload_json=parsed,
227
+ )
228
+ )
229
+
230
+ await self._dispatch_event(provider, parsed)
231
+ return {"ok": True}
232
+
233
+ # --- Ledger postings ------------------------------------------------------
234
+
235
+ async def _post_sale(self, pi_obj: dict):
236
+ provider_intent_id = pi_obj.get("id")
237
+ amount = int(pi_obj.get("amount") or 0)
238
+ currency = str(pi_obj.get("currency") or "USD").upper()
239
+ intent = await self.session.scalar(
240
+ select(PayIntent).where(PayIntent.provider_intent_id == provider_intent_id)
241
+ )
242
+ if intent:
243
+ intent.status = "succeeded"
244
+ self.session.add(
245
+ LedgerEntry(
246
+ tenant_id=self.tenant_id,
247
+ provider=intent.provider,
248
+ provider_ref=provider_intent_id,
249
+ user_id=intent.user_id,
250
+ amount=+amount,
251
+ currency=currency,
252
+ kind="payment",
253
+ status="posted",
254
+ )
255
+ )
256
+
257
+ async def _post_capture(self, charge_obj: dict):
258
+ amount = int(charge_obj.get("amount") or 0)
259
+ currency = str(charge_obj.get("currency") or "USD").upper()
260
+ pi_id = charge_obj.get("payment_intent") or ""
261
+ intent = await self.session.scalar(
262
+ select(PayIntent).where(PayIntent.provider_intent_id == pi_id)
263
+ )
264
+ if intent:
265
+ # Avoid duplicate capture entries
266
+ existing = await self.session.scalar(
267
+ select(LedgerEntry).where(
268
+ LedgerEntry.provider_ref == charge_obj.get("id"),
269
+ LedgerEntry.kind == "capture",
270
+ )
271
+ )
272
+ if not existing:
273
+ self.session.add(
274
+ LedgerEntry(
275
+ tenant_id=self.tenant_id,
276
+ provider=intent.provider,
277
+ provider_ref=charge_obj.get("id"),
278
+ user_id=intent.user_id,
279
+ amount=+amount,
280
+ currency=currency,
281
+ kind="capture",
282
+ status="posted",
283
+ )
284
+ )
285
+ intent.captured = True
286
+
287
+ async def _post_refund(self, charge_obj: dict):
288
+ amount = int(charge_obj.get("amount_refunded") or 0)
289
+ currency = str(charge_obj.get("currency") or "USD").upper()
290
+ pi_id = charge_obj.get("payment_intent") or ""
291
+ intent = await self.session.scalar(
292
+ select(PayIntent).where(PayIntent.provider_intent_id == pi_id)
293
+ )
294
+ if intent and amount > 0:
295
+ existing = await self.session.scalar(
296
+ select(LedgerEntry).where(
297
+ LedgerEntry.provider_ref == charge_obj.get("id"),
298
+ LedgerEntry.kind == "refund",
299
+ )
300
+ )
301
+ if not existing:
302
+ self.session.add(
303
+ LedgerEntry(
304
+ tenant_id=self.tenant_id,
305
+ provider=intent.provider,
306
+ provider_ref=charge_obj.get("id"),
307
+ user_id=intent.user_id,
308
+ amount=+amount,
309
+ currency=currency,
310
+ kind="refund",
311
+ status="posted",
312
+ )
313
+ )
314
+
315
+ async def attach_payment_method(self, data: PaymentMethodAttachIn) -> PaymentMethodOut:
316
+ out = await self._get_adapter().attach_payment_method(data)
317
+ # Upsert locally for quick listing
318
+ pm = PayPaymentMethod(
319
+ tenant_id=self.tenant_id,
320
+ provider=out.provider,
321
+ provider_customer_id=out.provider_customer_id,
322
+ provider_method_id=out.provider_method_id,
323
+ brand=out.brand,
324
+ last4=out.last4,
325
+ exp_month=out.exp_month,
326
+ exp_year=out.exp_year,
327
+ is_default=out.is_default,
328
+ )
329
+ self.session.add(pm)
330
+ return out
331
+
332
+ async def list_payment_methods(self, provider_customer_id: str) -> list[PaymentMethodOut]:
333
+ return await self._get_adapter().list_payment_methods(provider_customer_id)
334
+
335
+ async def detach_payment_method(self, provider_method_id: str) -> PaymentMethodOut:
336
+ return await self._get_adapter().detach_payment_method(provider_method_id)
337
+
338
+ async def set_default_payment_method(
339
+ self, provider_customer_id: str, provider_method_id: str
340
+ ) -> PaymentMethodOut:
341
+ return await self._get_adapter().set_default_payment_method(
342
+ provider_customer_id, provider_method_id
343
+ )
344
+
345
+ # --- Products/Prices ---
346
+ async def create_product(self, data: ProductCreateIn) -> ProductOut:
347
+ out = await self._get_adapter().create_product(data)
348
+ self.session.add(
349
+ PayProduct(
350
+ tenant_id=self.tenant_id,
351
+ provider=out.provider,
352
+ provider_product_id=out.provider_product_id,
353
+ name=out.name,
354
+ active=out.active,
355
+ )
356
+ )
357
+ return out
358
+
359
+ async def create_price(self, data: PriceCreateIn) -> PriceOut:
360
+ out = await self._get_adapter().create_price(data)
361
+ self.session.add(
362
+ PayPrice(
363
+ tenant_id=self.tenant_id,
364
+ provider=out.provider,
365
+ provider_price_id=out.provider_price_id,
366
+ provider_product_id=out.provider_product_id,
367
+ currency=out.currency,
368
+ unit_amount=out.unit_amount,
369
+ interval=out.interval,
370
+ trial_days=out.trial_days,
371
+ active=out.active,
372
+ )
373
+ )
374
+ return out
375
+
376
+ # --- Subscriptions ---
377
+ async def create_subscription(self, data: SubscriptionCreateIn) -> SubscriptionOut:
378
+ out = await self._get_adapter().create_subscription(data)
379
+ self.session.add(
380
+ PaySubscription(
381
+ tenant_id=self.tenant_id,
382
+ provider=out.provider,
383
+ provider_subscription_id=out.provider_subscription_id,
384
+ provider_price_id=out.provider_price_id,
385
+ status=out.status,
386
+ quantity=out.quantity,
387
+ cancel_at_period_end=out.cancel_at_period_end,
388
+ )
389
+ )
390
+ return out
391
+
392
+ async def update_subscription(
393
+ self, provider_subscription_id: str, data: SubscriptionUpdateIn
394
+ ) -> SubscriptionOut:
395
+ out = await self._get_adapter().update_subscription(provider_subscription_id, data)
396
+ # Optionally reflect status/quantity locally (query + update if exists)
397
+ return out
398
+
399
+ async def cancel_subscription(
400
+ self, provider_subscription_id: str, at_period_end: bool = True
401
+ ) -> SubscriptionOut:
402
+ out = await self._get_adapter().cancel_subscription(provider_subscription_id, at_period_end)
403
+ return out
404
+
405
+ # --- Invoices ---
406
+ async def create_invoice(self, data: InvoiceCreateIn) -> InvoiceOut:
407
+ out = await self._get_adapter().create_invoice(data)
408
+ self.session.add(
409
+ PayInvoice(
410
+ tenant_id=self.tenant_id,
411
+ provider=out.provider,
412
+ provider_invoice_id=out.provider_invoice_id,
413
+ provider_customer_id=out.provider_customer_id,
414
+ status=out.status,
415
+ amount_due=out.amount_due,
416
+ currency=out.currency,
417
+ hosted_invoice_url=out.hosted_invoice_url,
418
+ pdf_url=out.pdf_url,
419
+ )
420
+ )
421
+ return out
422
+
423
+ async def finalize_invoice(self, provider_invoice_id: str) -> InvoiceOut:
424
+ return await self._get_adapter().finalize_invoice(provider_invoice_id)
425
+
426
+ async def void_invoice(self, provider_invoice_id: str) -> InvoiceOut:
427
+ return await self._get_adapter().void_invoice(provider_invoice_id)
428
+
429
+ async def pay_invoice(self, provider_invoice_id: str) -> InvoiceOut:
430
+ return await self._get_adapter().pay_invoice(provider_invoice_id)
431
+
432
+ # --- Intents QoL ---
433
+ async def get_intent(self, provider_intent_id: str) -> IntentOut:
434
+ return await self._get_adapter().hydrate_intent(provider_intent_id)
435
+
436
+ # --- Statements/Rollups ---
437
+ async def daily_statements_rollup(
438
+ self, date_from: str | None = None, date_to: str | None = None
439
+ ) -> list[StatementRow]:
440
+ from datetime import datetime
441
+
442
+ from sqlalchemy import func
443
+
444
+ q = select(
445
+ func.date_trunc("day", LedgerEntry.ts).label("day"),
446
+ LedgerEntry.currency,
447
+ func.sum(func.case((LedgerEntry.kind == "payment", LedgerEntry.amount), else_=0)).label(
448
+ "gross"
449
+ ),
450
+ func.sum(func.case((LedgerEntry.kind == "refund", LedgerEntry.amount), else_=0)).label(
451
+ "refunds"
452
+ ),
453
+ func.sum(func.case((LedgerEntry.kind == "fee", LedgerEntry.amount), else_=0)).label(
454
+ "fees"
455
+ ),
456
+ func.count().label("count"),
457
+ )
458
+ if date_from:
459
+ try:
460
+ q = q.where(LedgerEntry.ts >= datetime.fromisoformat(date_from))
461
+ except Exception:
462
+ pass
463
+ if date_to:
464
+ try:
465
+ q = q.where(LedgerEntry.ts <= datetime.fromisoformat(date_to))
466
+ except Exception:
467
+ pass
468
+ q = q.group_by(func.date_trunc("day", LedgerEntry.ts), LedgerEntry.currency).order_by(
469
+ func.date_trunc("day", LedgerEntry.ts).desc()
470
+ )
471
+
472
+ rows = (await self.session.execute(q)).all()
473
+ out: list[StatementRow] = []
474
+ for day, currency, gross, refunds, fees, count in rows:
475
+ gross = int(gross or 0)
476
+ refunds = int(refunds or 0)
477
+ fees = int(fees or 0)
478
+ out.append(
479
+ StatementRow(
480
+ period_start=day.isoformat(),
481
+ period_end=day.isoformat(),
482
+ currency=str(currency).upper(),
483
+ gross=gross,
484
+ refunds=refunds,
485
+ fees=fees,
486
+ net=gross - refunds - fees,
487
+ count=int(count or 0),
488
+ )
489
+ )
490
+ return out
491
+
492
+ async def capture_intent(self, provider_intent_id: str, data: CaptureIn) -> IntentOut:
493
+ out = await self._get_adapter().capture_intent(
494
+ provider_intent_id, amount=int(data.amount) if data.amount is not None else None
495
+ )
496
+ pi = await self.session.scalar(
497
+ select(PayIntent).where(PayIntent.provider_intent_id == provider_intent_id)
498
+ )
499
+ if pi:
500
+ pi.status = out.status
501
+ if out.status in ("succeeded", "requires_capture"): # Stripe specifics vary
502
+ pi.captured = True if out.status == "succeeded" else pi.captured
503
+ # Add capture ledger entry if succeeded and not already posted
504
+ if out.status == "succeeded":
505
+ existing = await self.session.scalar(
506
+ select(LedgerEntry).where(
507
+ LedgerEntry.provider_ref == provider_intent_id,
508
+ LedgerEntry.kind == "capture",
509
+ )
510
+ )
511
+ if not existing:
512
+ self.session.add(
513
+ LedgerEntry(
514
+ tenant_id=self.tenant_id,
515
+ provider=pi.provider,
516
+ provider_ref=provider_intent_id,
517
+ user_id=pi.user_id,
518
+ amount=+out.amount,
519
+ currency=out.currency,
520
+ kind="capture",
521
+ status="posted",
522
+ )
523
+ )
524
+ return out
525
+
526
+ async def list_intents(self, f: IntentListFilter) -> tuple[list[IntentOut], str | None]:
527
+ return await self._get_adapter().list_intents(
528
+ customer_provider_id=f.customer_provider_id,
529
+ status=f.status,
530
+ limit=f.limit or 50,
531
+ cursor=f.cursor,
532
+ )
533
+
534
+ # ---- Invoices: lines/list/get/preview ----
535
+ async def add_invoice_line_item(
536
+ self, provider_invoice_id: str, data: InvoiceLineItemIn
537
+ ) -> InvoiceOut:
538
+ return await self._get_adapter().add_invoice_line_item(provider_invoice_id, data)
539
+
540
+ async def list_invoices(self, f: InvoicesListFilter) -> tuple[list[InvoiceOut], str | None]:
541
+ return await self._get_adapter().list_invoices(
542
+ customer_provider_id=f.customer_provider_id,
543
+ status=f.status,
544
+ limit=f.limit or 50,
545
+ cursor=f.cursor,
546
+ )
547
+
548
+ async def get_invoice(self, provider_invoice_id: str) -> InvoiceOut:
549
+ return await self._get_adapter().get_invoice(provider_invoice_id)
550
+
551
+ async def preview_invoice(
552
+ self, customer_provider_id: str, subscription_id: str | None
553
+ ) -> InvoiceOut:
554
+ return await self._get_adapter().preview_invoice(
555
+ customer_provider_id=customer_provider_id, subscription_id=subscription_id
556
+ )
557
+
558
+ # ---- Metered usage ----
559
+ async def create_usage_record(self, data: UsageRecordIn) -> UsageRecordOut:
560
+ return await self._get_adapter().create_usage_record(data)
561
+
562
+ # --- Setup Intents --------------------------------------------------------
563
+ async def create_setup_intent(self, data: SetupIntentCreateIn) -> SetupIntentOut:
564
+ out = await self._get_adapter().create_setup_intent(data)
565
+ self.session.add(
566
+ PaySetupIntent(
567
+ tenant_id=self.tenant_id,
568
+ provider=out.provider,
569
+ provider_setup_intent_id=out.provider_setup_intent_id,
570
+ user_id=None,
571
+ status=out.status,
572
+ client_secret=out.client_secret,
573
+ )
574
+ )
575
+ return out
576
+
577
+ async def confirm_setup_intent(self, provider_setup_intent_id: str) -> SetupIntentOut:
578
+ out = await self._get_adapter().confirm_setup_intent(provider_setup_intent_id)
579
+ row = await self.session.scalar(
580
+ select(PaySetupIntent).where(
581
+ PaySetupIntent.provider_setup_intent_id == provider_setup_intent_id
582
+ )
583
+ )
584
+ if row:
585
+ row.status = out.status
586
+ row.client_secret = out.client_secret or row.client_secret
587
+ return out
588
+
589
+ async def get_setup_intent(self, provider_setup_intent_id: str) -> SetupIntentOut:
590
+ out = await self._get_adapter().get_setup_intent(provider_setup_intent_id)
591
+ # opportunistic upsert
592
+ row = await self.session.scalar(
593
+ select(PaySetupIntent).where(
594
+ PaySetupIntent.provider_setup_intent_id == provider_setup_intent_id
595
+ )
596
+ )
597
+ if row:
598
+ row.status = out.status
599
+ row.client_secret = out.client_secret or row.client_secret
600
+ else:
601
+ self.session.add(
602
+ PaySetupIntent(
603
+ tenant_id=self.tenant_id,
604
+ provider=out.provider,
605
+ provider_setup_intent_id=out.provider_setup_intent_id,
606
+ user_id=None,
607
+ status=out.status,
608
+ client_secret=out.client_secret,
609
+ )
610
+ )
611
+ return out
612
+
613
+ # --- SCA / 3DS resume -----------------------------------------------------
614
+ async def resume_intent_after_action(self, provider_intent_id: str) -> IntentOut:
615
+ out = await self._get_adapter().resume_intent_after_action(provider_intent_id)
616
+ pi = await self.session.scalar(
617
+ select(PayIntent).where(PayIntent.provider_intent_id == provider_intent_id)
618
+ )
619
+ if pi:
620
+ pi.status = out.status
621
+ pi.client_secret = out.client_secret or pi.client_secret
622
+ return out
623
+
624
+ # --- Disputes -------------------------------------------------------------
625
+ async def list_disputes(
626
+ self, *, status: Optional[str], limit: int, cursor: Optional[str]
627
+ ) -> tuple[list[DisputeOut], Optional[str]]:
628
+ return await self._get_adapter().list_disputes(status=status, limit=limit, cursor=cursor)
629
+
630
+ async def get_dispute(self, provider_dispute_id: str) -> DisputeOut:
631
+ out = await self._get_adapter().get_dispute(provider_dispute_id)
632
+ # Upsert locally
633
+ row = await self.session.scalar(
634
+ select(PayDispute).where(PayDispute.provider_dispute_id == provider_dispute_id)
635
+ )
636
+ if row:
637
+ row.status = out.status
638
+ row.amount = out.amount
639
+ row.currency = out.currency
640
+ else:
641
+ self.session.add(
642
+ PayDispute(
643
+ tenant_id=self.tenant_id,
644
+ provider=out.provider,
645
+ provider_dispute_id=out.provider_dispute_id,
646
+ provider_charge_id=None, # set if adapter returns it
647
+ amount=out.amount,
648
+ currency=out.currency,
649
+ reason=out.reason,
650
+ status=out.status,
651
+ )
652
+ )
653
+ return out
654
+
655
+ async def submit_dispute_evidence(self, provider_dispute_id: str, evidence: dict) -> DisputeOut:
656
+ out = await self._get_adapter().submit_dispute_evidence(provider_dispute_id, evidence)
657
+ # reflect status
658
+ row = await self.session.scalar(
659
+ select(PayDispute).where(PayDispute.provider_dispute_id == provider_dispute_id)
660
+ )
661
+ if row:
662
+ row.status = out.status
663
+ return out
664
+
665
+ # --- Balance --------------------------------------------------------------
666
+ async def get_balance_snapshot(self) -> BalanceSnapshotOut:
667
+ return await self._get_adapter().get_balance_snapshot()
668
+
669
+ # --- Payouts --------------------------------------------------------------
670
+ async def list_payouts(
671
+ self, *, limit: int, cursor: Optional[str]
672
+ ) -> tuple[list[PayoutOut], Optional[str]]:
673
+ return await self._get_adapter().list_payouts(limit=limit, cursor=cursor)
674
+
675
+ async def get_payout(self, provider_payout_id: str) -> PayoutOut:
676
+ out = await self._get_adapter().get_payout(provider_payout_id)
677
+ # Upsert locally
678
+ row = await self.session.scalar(
679
+ select(PayPayout).where(PayPayout.provider_payout_id == provider_payout_id)
680
+ )
681
+ if row:
682
+ row.status = out.status
683
+ row.amount = out.amount
684
+ row.currency = out.currency
685
+ # arrival_date/type optional; update if present
686
+ else:
687
+ self.session.add(
688
+ PayPayout(
689
+ tenant_id=self.tenant_id,
690
+ provider=out.provider,
691
+ provider_payout_id=out.provider_payout_id,
692
+ amount=out.amount,
693
+ currency=out.currency,
694
+ status=out.status,
695
+ # arrival_date/out.type if you add them onto PayoutOut
696
+ )
697
+ )
698
+ return out
699
+
700
+ # --- Webhook replay -------------------------------------------------------
701
+ async def replay_webhooks(
702
+ self, since: Optional[str], until: Optional[str], event_ids: list[str]
703
+ ) -> int:
704
+ from datetime import datetime
705
+
706
+ q = select(PayEvent).where(PayEvent.provider == self._provider_name)
707
+ if event_ids:
708
+ q = q.where(PayEvent.provider_event_id.in_(event_ids))
709
+ else:
710
+ # ISO8601 strings expected; ignore parsing errors safely
711
+ if since:
712
+ try:
713
+ q = q.where(PayEvent.received_at >= datetime.fromisoformat(since))
714
+ except Exception:
715
+ pass
716
+ if until:
717
+ try:
718
+ q = q.where(PayEvent.received_at <= datetime.fromisoformat(until))
719
+ except Exception:
720
+ pass
721
+
722
+ rows = (await self.session.execute(q)).scalars().all()
723
+ for ev in rows:
724
+ await self._dispatch_event(ev.provider, ev.payload_json)
725
+
726
+ return len(rows)
727
+
728
+ # ---- Customers ----
729
+ async def list_customers(self, f: CustomersListFilter) -> tuple[list[CustomerOut], str | None]:
730
+ adapter = self._get_adapter()
731
+ try:
732
+ return await adapter.list_customers(
733
+ provider=f.provider, user_id=f.user_id, limit=f.limit or 50, cursor=f.cursor
734
+ )
735
+ except NotImplementedError:
736
+ # Fallback to local DB listing
737
+ q = select(PayCustomer).order_by(PayCustomer.provider_customer_id.asc())
738
+ if f.provider:
739
+ q = q.where(PayCustomer.provider == f.provider)
740
+ if f.user_id:
741
+ q = q.where(PayCustomer.user_id == f.user_id)
742
+ rows = (await self.session.execute(q)).scalars().all()
743
+ # simple cursor by provider_customer_id; production can optimize
744
+ next_cursor = None
745
+ if f.limit and len(rows) > f.limit:
746
+ rows = rows[: f.limit]
747
+ next_cursor = rows[-1].provider_customer_id
748
+ return (
749
+ [
750
+ CustomerOut(
751
+ id=r.id,
752
+ provider=r.provider,
753
+ provider_customer_id=r.provider_customer_id,
754
+ email=None,
755
+ name=None,
756
+ )
757
+ for r in rows
758
+ ],
759
+ next_cursor,
760
+ )
761
+
762
+ async def get_customer(self, provider_customer_id: str) -> CustomerOut:
763
+ adapter = self._get_adapter()
764
+ out = await adapter.get_customer(provider_customer_id)
765
+ if out is None:
766
+ raise RuntimeError("Customer not found")
767
+ # upsert locally
768
+ row = await self.session.scalar(
769
+ select(PayCustomer).where(PayCustomer.provider_customer_id == provider_customer_id)
770
+ )
771
+ if not row:
772
+ self.session.add(
773
+ PayCustomer(
774
+ tenant_id=self.tenant_id,
775
+ provider=out.provider,
776
+ provider_customer_id=out.provider_customer_id,
777
+ user_id=None,
778
+ )
779
+ )
780
+ return out
781
+
782
+ # ---- Products / Prices ----
783
+ async def get_product(self, provider_product_id: str) -> ProductOut:
784
+ return await self._get_adapter().get_product(provider_product_id)
785
+
786
+ async def list_products(
787
+ self, *, active: bool | None, limit: int, cursor: str | None
788
+ ) -> tuple[list[ProductOut], str | None]:
789
+ return await self._get_adapter().list_products(active=active, limit=limit, cursor=cursor)
790
+
791
+ async def update_product(self, provider_product_id: str, data: ProductUpdateIn) -> ProductOut:
792
+ out = await self._get_adapter().update_product(provider_product_id, data)
793
+ # reflect DB
794
+ row = await self.session.scalar(
795
+ select(PayProduct).where(PayProduct.provider_product_id == provider_product_id)
796
+ )
797
+ if row:
798
+ if data.name is not None:
799
+ row.name = data.name
800
+ if data.active is not None:
801
+ row.active = data.active
802
+ return out
803
+
804
+ async def get_price(self, provider_price_id: str) -> PriceOut:
805
+ return await self._get_adapter().get_price(provider_price_id)
806
+
807
+ async def list_prices(
808
+ self,
809
+ *,
810
+ provider_product_id: str | None,
811
+ active: bool | None,
812
+ limit: int,
813
+ cursor: str | None,
814
+ ) -> tuple[list[PriceOut], str | None]:
815
+ return await self._get_adapter().list_prices(
816
+ provider_product_id=provider_product_id, active=active, limit=limit, cursor=cursor
817
+ )
818
+
819
+ async def update_price(self, provider_price_id: str, data: PriceUpdateIn) -> PriceOut:
820
+ out = await self._get_adapter().update_price(provider_price_id, data)
821
+ row = await self.session.scalar(
822
+ select(PayPrice).where(PayPrice.provider_price_id == provider_price_id)
823
+ )
824
+ if row and data.active is not None:
825
+ row.active = data.active
826
+ return out
827
+
828
+ # ---- Subscriptions ----
829
+ async def get_subscription(self, provider_subscription_id: str) -> SubscriptionOut:
830
+ return await self._get_adapter().get_subscription(provider_subscription_id)
831
+
832
+ async def list_subscriptions(
833
+ self,
834
+ *,
835
+ customer_provider_id: str | None,
836
+ status: str | None,
837
+ limit: int,
838
+ cursor: str | None,
839
+ ) -> tuple[list[SubscriptionOut], str | None]:
840
+ return await self._get_adapter().list_subscriptions(
841
+ customer_provider_id=customer_provider_id, status=status, limit=limit, cursor=cursor
842
+ )
843
+
844
+ # ---- Payment Methods (get/update) ----
845
+ async def get_payment_method(self, provider_method_id: str) -> PaymentMethodOut:
846
+ return await self._get_adapter().get_payment_method(provider_method_id)
847
+
848
+ async def update_payment_method(
849
+ self, provider_method_id: str, data: PaymentMethodUpdateIn
850
+ ) -> PaymentMethodOut:
851
+ out = await self._get_adapter().update_payment_method(provider_method_id, data)
852
+ row = await self.session.scalar(
853
+ select(PayPaymentMethod).where(
854
+ PayPaymentMethod.provider_method_id == provider_method_id
855
+ )
856
+ )
857
+ if row:
858
+ if data.name is not None:
859
+ pass # keep local-only if/when you add column
860
+ if data.exp_month is not None:
861
+ row.exp_month = data.exp_month
862
+ if data.exp_year is not None:
863
+ row.exp_year = data.exp_year
864
+ return out
865
+
866
+ # ---- Refunds list/get ----
867
+ async def list_refunds(
868
+ self, *, provider_payment_intent_id: str | None, limit: int, cursor: str | None
869
+ ) -> tuple[list[RefundOut], str | None]:
870
+ return await self._get_adapter().list_refunds(
871
+ provider_payment_intent_id=provider_payment_intent_id, limit=limit, cursor=cursor
872
+ )
873
+
874
+ async def get_refund(self, provider_refund_id: str) -> RefundOut:
875
+ return await self._get_adapter().get_refund(provider_refund_id)
876
+
877
+ # ---- Invoice line items list ----
878
+ async def list_invoice_line_items(
879
+ self, provider_invoice_id: str, *, limit: int, cursor: str | None
880
+ ) -> tuple[list[InvoiceLineItemOut], str | None]:
881
+ return await self._get_adapter().list_invoice_line_items(
882
+ provider_invoice_id, limit=limit, cursor=cursor
883
+ )
884
+
885
+ # ---- Usage records list/get ----
886
+ async def list_usage_records(
887
+ self, f: UsageRecordListFilter
888
+ ) -> tuple[list[UsageRecordOut], str | None]:
889
+ return await self._get_adapter().list_usage_records(f)
890
+
891
+ async def get_usage_record(self, usage_record_id: str) -> UsageRecordOut:
892
+ return await self._get_adapter().get_usage_record(usage_record_id)