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.
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/alembic.py +11 -0
- svc_infra/apf_payments/models.py +339 -0
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +797 -0
- svc_infra/apf_payments/provider/base.py +270 -0
- svc_infra/apf_payments/provider/registry.py +31 -0
- svc_infra/apf_payments/provider/stripe.py +873 -0
- svc_infra/apf_payments/schemas.py +333 -0
- svc_infra/apf_payments/service.py +892 -0
- svc_infra/apf_payments/settings.py +67 -0
- svc_infra/api/fastapi/__init__.py +6 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
- svc_infra/api/fastapi/apf_payments/router.py +1082 -0
- svc_infra/api/fastapi/apf_payments/setup.py +73 -0
- svc_infra/api/fastapi/auth/add.py +15 -6
- svc_infra/api/fastapi/auth/gaurd.py +67 -5
- svc_infra/api/fastapi/auth/mfa/router.py +18 -9
- svc_infra/api/fastapi/auth/routers/account.py +3 -2
- svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
- svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
- svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
- svc_infra/api/fastapi/auth/security.py +3 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +1 -1
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/db/sql/users.py +14 -2
- svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +254 -0
- svc_infra/api/fastapi/dual/dualize.py +38 -33
- svc_infra/api/fastapi/dual/router.py +48 -1
- svc_infra/api/fastapi/dx.py +3 -3
- svc_infra/api/fastapi/http/__init__.py +0 -0
- svc_infra/api/fastapi/http/concurrency.py +14 -0
- svc_infra/api/fastapi/http/conditional.py +33 -0
- svc_infra/api/fastapi/http/deprecation.py +21 -0
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/idempotency.py +116 -0
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
- svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
- svc_infra/api/fastapi/middleware/request_id.py +23 -0
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +768 -55
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +363 -0
- svc_infra/api/fastapi/paths/auth.py +14 -14
- svc_infra/api/fastapi/paths/prefix.py +0 -1
- svc_infra/api/fastapi/paths/user.py +1 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +48 -15
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +32 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +10 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/outbox.py +104 -0
- svc_infra/db/sql/apikey.py +1 -1
- svc_infra/db/sql/authref.py +61 -0
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/repository.py +52 -12
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +16 -4
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/docs/acceptance-matrix.md +71 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/api.md +59 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +90 -0
- svc_infra/jobs/easy.py +32 -0
- svc_infra/jobs/loader.py +45 -0
- svc_infra/jobs/queue.py +81 -0
- svc_infra/jobs/redis_queue.py +191 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +41 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +53 -0
- svc_infra/obs/metrics.py +52 -0
- svc_infra/security/add.py +201 -0
- svc_infra/security/audit.py +130 -0
- svc_infra/security/audit_service.py +73 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +95 -0
- svc_infra/security/jwt_rotation.py +53 -0
- svc_infra/security/lockout.py +96 -0
- svc_infra/security/models.py +255 -0
- svc_infra/security/org_invites.py +128 -0
- svc_infra/security/passwords.py +77 -0
- svc_infra/security/permissions.py +149 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +80 -0
- svc_infra/webhooks/__init__.py +16 -0
- svc_infra/webhooks/add.py +322 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +67 -0
- svc_infra/webhooks/signing.py +30 -0
- svc_infra-0.1.654.dist-info/METADATA +154 -0
- svc_infra-0.1.654.dist-info/RECORD +352 -0
- svc_infra/api/fastapi/deps.py +0 -3
- svc_infra-0.1.506.dist-info/METADATA +0 -78
- svc_infra-0.1.506.dist-info/RECORD +0 -213
- /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
- {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)
|