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
@@ -8,33 +8,54 @@ from sqlalchemy.ext.asyncio import AsyncSession
8
8
  from .models import (
9
9
  LedgerEntry,
10
10
  PayCustomer,
11
+ PayDispute,
11
12
  PayEvent,
12
13
  PayIntent,
13
14
  PayInvoice,
14
15
  PayPaymentMethod,
16
+ PayPayout,
15
17
  PayPrice,
16
18
  PayProduct,
19
+ PaySetupIntent,
17
20
  PaySubscription,
18
21
  )
19
22
  from .provider.registry import get_provider_registry
20
23
  from .schemas import (
24
+ BalanceSnapshotOut,
25
+ CaptureIn,
21
26
  CustomerOut,
27
+ CustomersListFilter,
22
28
  CustomerUpsertIn,
29
+ DisputeOut,
23
30
  IntentCreateIn,
31
+ IntentListFilter,
24
32
  IntentOut,
25
33
  InvoiceCreateIn,
34
+ InvoiceLineItemIn,
35
+ InvoiceLineItemOut,
26
36
  InvoiceOut,
37
+ InvoicesListFilter,
27
38
  PaymentMethodAttachIn,
28
39
  PaymentMethodOut,
40
+ PaymentMethodUpdateIn,
41
+ PayoutOut,
29
42
  PriceCreateIn,
30
43
  PriceOut,
44
+ PriceUpdateIn,
31
45
  ProductCreateIn,
32
46
  ProductOut,
47
+ ProductUpdateIn,
33
48
  RefundIn,
49
+ RefundOut,
50
+ SetupIntentCreateIn,
51
+ SetupIntentOut,
34
52
  StatementRow,
35
53
  SubscriptionCreateIn,
36
54
  SubscriptionOut,
37
55
  SubscriptionUpdateIn,
56
+ UsageRecordIn,
57
+ UsageRecordListFilter,
58
+ UsageRecordOut,
38
59
  )
39
60
  from .settings import get_payments_settings
40
61
 
@@ -44,9 +65,24 @@ def _default_provider_name() -> str:
44
65
 
45
66
 
46
67
  class PaymentsService:
47
-
48
- def __init__(self, session: AsyncSession, provider_name: Optional[str] = None):
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")
49
84
  self.session = session
85
+ self.tenant_id = tenant_id
50
86
  self._provider_name = (provider_name or _default_provider_name()).lower()
51
87
  self._adapter = None # resolved on first use
52
88
 
@@ -68,6 +104,19 @@ class PaymentsService:
68
104
  ) from e
69
105
  return self._adapter
70
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
+
71
120
  # --- Customers ------------------------------------------------------------
72
121
 
73
122
  async def ensure_customer(self, data: CustomerUpsertIn) -> CustomerOut:
@@ -84,6 +133,7 @@ class PaymentsService:
84
133
  # If your PayCustomer model has additional columns (email/name), include them here.
85
134
  self.session.add(
86
135
  PayCustomer(
136
+ tenant_id=self.tenant_id,
87
137
  provider=out.provider,
88
138
  provider_customer_id=out.provider_customer_id,
89
139
  user_id=data.user_id,
@@ -98,6 +148,7 @@ class PaymentsService:
98
148
  out = await adapter.create_intent(data, user_id=user_id)
99
149
  self.session.add(
100
150
  PayIntent(
151
+ tenant_id=self.tenant_id,
101
152
  provider=out.provider,
102
153
  provider_intent_id=out.provider_intent_id,
103
154
  user_id=user_id,
@@ -133,35 +184,50 @@ class PaymentsService:
133
184
  async def refund(self, provider_intent_id: str, data: RefundIn) -> IntentOut:
134
185
  adapter = self._get_adapter()
135
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
+ )
136
213
  return out
137
214
 
138
215
  # --- Webhooks -------------------------------------------------------------
139
216
 
140
217
  async def handle_webhook(self, provider: str, signature: str | None, payload: bytes) -> dict:
141
- # Webhooks also require provider adapter
142
218
  adapter = self._get_adapter()
143
219
  parsed = await adapter.verify_and_parse_webhook(signature, payload)
144
-
145
- # Save raw event (keep JSON column/shape aligned with your model)
146
220
  self.session.add(
147
221
  PayEvent(
222
+ tenant_id=self.tenant_id,
148
223
  provider=provider,
149
224
  provider_event_id=parsed["id"],
150
- payload_json=parsed, # or serialize before assign if your column is Text
225
+ type=parsed.get("type", ""),
226
+ payload_json=parsed,
151
227
  )
152
228
  )
153
229
 
154
- typ = parsed.get("type", "")
155
- obj = parsed.get("data") or {}
156
-
157
- if provider == "stripe":
158
- if typ == "payment_intent.succeeded":
159
- await self._post_sale(obj)
160
- elif typ == "charge.refunded":
161
- await self._post_refund(obj)
162
- elif typ == "charge.captured":
163
- await self._post_capture(obj)
164
-
230
+ await self._dispatch_event(provider, parsed)
165
231
  return {"ok": True}
166
232
 
167
233
  # --- Ledger postings ------------------------------------------------------
@@ -177,6 +243,7 @@ class PaymentsService:
177
243
  intent.status = "succeeded"
178
244
  self.session.add(
179
245
  LedgerEntry(
246
+ tenant_id=self.tenant_id,
180
247
  provider=intent.provider,
181
248
  provider_ref=provider_intent_id,
182
249
  user_id=intent.user_id,
@@ -195,17 +262,27 @@ class PaymentsService:
195
262
  select(PayIntent).where(PayIntent.provider_intent_id == pi_id)
196
263
  )
197
264
  if intent:
198
- self.session.add(
199
- LedgerEntry(
200
- provider=intent.provider,
201
- provider_ref=charge_obj.get("id"),
202
- user_id=intent.user_id,
203
- amount=+amount,
204
- currency=currency,
205
- kind="capture",
206
- status="posted",
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",
207
270
  )
208
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
209
286
 
210
287
  async def _post_refund(self, charge_obj: dict):
211
288
  amount = int(charge_obj.get("amount_refunded") or 0)
@@ -215,22 +292,31 @@ class PaymentsService:
215
292
  select(PayIntent).where(PayIntent.provider_intent_id == pi_id)
216
293
  )
217
294
  if intent and amount > 0:
218
- self.session.add(
219
- LedgerEntry(
220
- provider=intent.provider,
221
- provider_ref=charge_obj.get("id"),
222
- user_id=intent.user_id,
223
- amount=+amount,
224
- currency=currency,
225
- kind="refund",
226
- status="posted",
295
+ existing = await self.session.scalar(
296
+ select(LedgerEntry).where(
297
+ LedgerEntry.provider_ref == charge_obj.get("id"),
298
+ LedgerEntry.kind == "refund",
227
299
  )
228
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
+ )
229
314
 
230
315
  async def attach_payment_method(self, data: PaymentMethodAttachIn) -> PaymentMethodOut:
231
316
  out = await self._get_adapter().attach_payment_method(data)
232
317
  # Upsert locally for quick listing
233
318
  pm = PayPaymentMethod(
319
+ tenant_id=self.tenant_id,
234
320
  provider=out.provider,
235
321
  provider_customer_id=out.provider_customer_id,
236
322
  provider_method_id=out.provider_method_id,
@@ -246,13 +332,13 @@ class PaymentsService:
246
332
  async def list_payment_methods(self, provider_customer_id: str) -> list[PaymentMethodOut]:
247
333
  return await self._get_adapter().list_payment_methods(provider_customer_id)
248
334
 
249
- async def detach_payment_method(self, provider_method_id: str) -> None:
250
- await self._get_adapter().detach_payment_method(provider_method_id)
335
+ async def detach_payment_method(self, provider_method_id: str) -> PaymentMethodOut:
336
+ return await self._get_adapter().detach_payment_method(provider_method_id)
251
337
 
252
338
  async def set_default_payment_method(
253
339
  self, provider_customer_id: str, provider_method_id: str
254
- ) -> None:
255
- await self._get_adapter().set_default_payment_method(
340
+ ) -> PaymentMethodOut:
341
+ return await self._get_adapter().set_default_payment_method(
256
342
  provider_customer_id, provider_method_id
257
343
  )
258
344
 
@@ -261,6 +347,7 @@ class PaymentsService:
261
347
  out = await self._get_adapter().create_product(data)
262
348
  self.session.add(
263
349
  PayProduct(
350
+ tenant_id=self.tenant_id,
264
351
  provider=out.provider,
265
352
  provider_product_id=out.provider_product_id,
266
353
  name=out.name,
@@ -273,6 +360,7 @@ class PaymentsService:
273
360
  out = await self._get_adapter().create_price(data)
274
361
  self.session.add(
275
362
  PayPrice(
363
+ tenant_id=self.tenant_id,
276
364
  provider=out.provider,
277
365
  provider_price_id=out.provider_price_id,
278
366
  provider_product_id=out.provider_product_id,
@@ -290,6 +378,7 @@ class PaymentsService:
290
378
  out = await self._get_adapter().create_subscription(data)
291
379
  self.session.add(
292
380
  PaySubscription(
381
+ tenant_id=self.tenant_id,
293
382
  provider=out.provider,
294
383
  provider_subscription_id=out.provider_subscription_id,
295
384
  provider_price_id=out.provider_price_id,
@@ -318,6 +407,7 @@ class PaymentsService:
318
407
  out = await self._get_adapter().create_invoice(data)
319
408
  self.session.add(
320
409
  PayInvoice(
410
+ tenant_id=self.tenant_id,
321
411
  provider=out.provider,
322
412
  provider_invoice_id=out.provider_invoice_id,
323
413
  provider_customer_id=out.provider_customer_id,
@@ -347,6 +437,456 @@ class PaymentsService:
347
437
  async def daily_statements_rollup(
348
438
  self, date_from: str | None = None, date_to: str | None = None
349
439
  ) -> list[StatementRow]:
350
- # simple SQL rollup across LedgerEntry; filter by ts range if provided
351
- # (left as exercise: GROUP BY currency; SUM amounts by kind; compute net=payments - refunds - fees)
352
- return []
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)