jb-drf-billing 0.1.0__tar.gz
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.
- jb_drf_billing-0.1.0/PKG-INFO +30 -0
- jb_drf_billing-0.1.0/README.md +19 -0
- jb_drf_billing-0.1.0/jb_drf_billing/__init__.py +1 -0
- jb_drf_billing-0.1.0/jb_drf_billing/adapters/__init__.py +3 -0
- jb_drf_billing-0.1.0/jb_drf_billing/adapters/base.py +8 -0
- jb_drf_billing-0.1.0/jb_drf_billing/adapters/revenuecat.py +393 -0
- jb_drf_billing-0.1.0/jb_drf_billing/adapters/stripe.py +96 -0
- jb_drf_billing-0.1.0/jb_drf_billing/admin.py +161 -0
- jb_drf_billing-0.1.0/jb_drf_billing/apps.py +10 -0
- jb_drf_billing-0.1.0/jb_drf_billing/catalog/__init__.py +3 -0
- jb_drf_billing-0.1.0/jb_drf_billing/catalog/base.py +3 -0
- jb_drf_billing-0.1.0/jb_drf_billing/catalog/db_provider.py +71 -0
- jb_drf_billing-0.1.0/jb_drf_billing/catalog/settings_provider.py +12 -0
- jb_drf_billing-0.1.0/jb_drf_billing/checks.py +41 -0
- jb_drf_billing-0.1.0/jb_drf_billing/conf.py +126 -0
- jb_drf_billing-0.1.0/jb_drf_billing/exceptions.py +2 -0
- jb_drf_billing-0.1.0/jb_drf_billing/models/__init__.py +1 -0
- jb_drf_billing-0.1.0/jb_drf_billing/models/base.py +218 -0
- jb_drf_billing-0.1.0/jb_drf_billing/permissions.py +5 -0
- jb_drf_billing-0.1.0/jb_drf_billing/policies/__init__.py +2 -0
- jb_drf_billing-0.1.0/jb_drf_billing/policies/access.py +9 -0
- jb_drf_billing-0.1.0/jb_drf_billing/policies/features.py +3 -0
- jb_drf_billing-0.1.0/jb_drf_billing/serializers/__init__.py +3 -0
- jb_drf_billing-0.1.0/jb_drf_billing/serializers/access.py +16 -0
- jb_drf_billing-0.1.0/jb_drf_billing/serializers/catalog.py +7 -0
- jb_drf_billing-0.1.0/jb_drf_billing/serializers/mobile.py +6 -0
- jb_drf_billing-0.1.0/jb_drf_billing/serializers/status.py +9 -0
- jb_drf_billing-0.1.0/jb_drf_billing/serializers/web.py +16 -0
- jb_drf_billing-0.1.0/jb_drf_billing/serializers/webhooks.py +5 -0
- jb_drf_billing-0.1.0/jb_drf_billing/services/__init__.py +4 -0
- jb_drf_billing-0.1.0/jb_drf_billing/services/access.py +44 -0
- jb_drf_billing-0.1.0/jb_drf_billing/services/catalog.py +15 -0
- jb_drf_billing-0.1.0/jb_drf_billing/services/entitlements.py +116 -0
- jb_drf_billing-0.1.0/jb_drf_billing/services/mobile_sync.py +16 -0
- jb_drf_billing-0.1.0/jb_drf_billing/services/status.py +82 -0
- jb_drf_billing-0.1.0/jb_drf_billing/services/web_checkout.py +101 -0
- jb_drf_billing-0.1.0/jb_drf_billing/services/webhooks.py +20 -0
- jb_drf_billing-0.1.0/jb_drf_billing/signals.py +8 -0
- jb_drf_billing-0.1.0/jb_drf_billing/tests/__init__.py +0 -0
- jb_drf_billing-0.1.0/jb_drf_billing/tests/settings.py +28 -0
- jb_drf_billing-0.1.0/jb_drf_billing/tests/test_access.py +1 -0
- jb_drf_billing-0.1.0/jb_drf_billing/tests/test_checks.py +10 -0
- jb_drf_billing-0.1.0/jb_drf_billing/tests/test_conf.py +13 -0
- jb_drf_billing-0.1.0/jb_drf_billing/tests/test_entitlements.py +1 -0
- jb_drf_billing-0.1.0/jb_drf_billing/tests/test_status.py +1 -0
- jb_drf_billing-0.1.0/jb_drf_billing/tests/test_urls.py +7 -0
- jb_drf_billing-0.1.0/jb_drf_billing/tests/test_webhooks.py +1 -0
- jb_drf_billing-0.1.0/jb_drf_billing/urls.py +29 -0
- jb_drf_billing-0.1.0/jb_drf_billing/utils.py +7 -0
- jb_drf_billing-0.1.0/jb_drf_billing/views/__init__.py +7 -0
- jb_drf_billing-0.1.0/jb_drf_billing/views/access.py +26 -0
- jb_drf_billing-0.1.0/jb_drf_billing/views/catalog.py +21 -0
- jb_drf_billing-0.1.0/jb_drf_billing/views/entitlements.py +28 -0
- jb_drf_billing-0.1.0/jb_drf_billing/views/mobile.py +29 -0
- jb_drf_billing-0.1.0/jb_drf_billing/views/status.py +14 -0
- jb_drf_billing-0.1.0/jb_drf_billing/views/web.py +35 -0
- jb_drf_billing-0.1.0/jb_drf_billing/views/webhooks.py +26 -0
- jb_drf_billing-0.1.0/jb_drf_billing.egg-info/PKG-INFO +30 -0
- jb_drf_billing-0.1.0/jb_drf_billing.egg-info/SOURCES.txt +62 -0
- jb_drf_billing-0.1.0/jb_drf_billing.egg-info/dependency_links.txt +1 -0
- jb_drf_billing-0.1.0/jb_drf_billing.egg-info/requires.txt +4 -0
- jb_drf_billing-0.1.0/jb_drf_billing.egg-info/top_level.txt +1 -0
- jb_drf_billing-0.1.0/pyproject.toml +16 -0
- jb_drf_billing-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jb-drf-billing
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reusable billing/subscriptions foundations for Django/DRF projects
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: Django>=5.0
|
|
8
|
+
Requires-Dist: djangorestframework>=3.15
|
|
9
|
+
Requires-Dist: jb-drf-auth>=0.1.17
|
|
10
|
+
Requires-Dist: requests>=2.31
|
|
11
|
+
|
|
12
|
+
# jb-drf-billing
|
|
13
|
+
|
|
14
|
+
Reusable billing/subscriptions foundations for Django/DRF projects.
|
|
15
|
+
|
|
16
|
+
Pattern:
|
|
17
|
+
- abstract models in this package
|
|
18
|
+
- concrete models + migrations in integrator projects
|
|
19
|
+
- configuration via `JB_DRF_BILLING`
|
|
20
|
+
|
|
21
|
+
## Roadmap
|
|
22
|
+
|
|
23
|
+
- **Phase 1-2 (done):** core lib, abstract models, RevenueCat adapter, admin pattern.
|
|
24
|
+
- **Phase 3 (in progress):** mobile + RevenueCat real (trial configurable, mobile gating).
|
|
25
|
+
- **Phase 4:** Stripe adapter (web + Android checkout, customer portal, real webhook signature verification). See [`PHASE_4_TODO.md`](./PHASE_4_TODO.md) for the actionable checklist.
|
|
26
|
+
|
|
27
|
+
To find all in-code markers for Phase 4 work:
|
|
28
|
+
```bash
|
|
29
|
+
grep -r "TODO\[phase-4-stripe\]" .
|
|
30
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# jb-drf-billing
|
|
2
|
+
|
|
3
|
+
Reusable billing/subscriptions foundations for Django/DRF projects.
|
|
4
|
+
|
|
5
|
+
Pattern:
|
|
6
|
+
- abstract models in this package
|
|
7
|
+
- concrete models + migrations in integrator projects
|
|
8
|
+
- configuration via `JB_DRF_BILLING`
|
|
9
|
+
|
|
10
|
+
## Roadmap
|
|
11
|
+
|
|
12
|
+
- **Phase 1-2 (done):** core lib, abstract models, RevenueCat adapter, admin pattern.
|
|
13
|
+
- **Phase 3 (in progress):** mobile + RevenueCat real (trial configurable, mobile gating).
|
|
14
|
+
- **Phase 4:** Stripe adapter (web + Android checkout, customer portal, real webhook signature verification). See [`PHASE_4_TODO.md`](./PHASE_4_TODO.md) for the actionable checklist.
|
|
15
|
+
|
|
16
|
+
To find all in-code markers for Phase 4 work:
|
|
17
|
+
```bash
|
|
18
|
+
grep -r "TODO\[phase-4-stripe\]" .
|
|
19
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
default_app_config = "jb_drf_billing.apps.JbDrfBillingConfig"
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
from datetime import datetime, timezone as dt_timezone
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
from django.contrib.auth import get_user_model
|
|
8
|
+
from django.db import transaction
|
|
9
|
+
from django.utils import timezone
|
|
10
|
+
|
|
11
|
+
from jb_drf_billing.adapters.base import BillingProviderAdapter
|
|
12
|
+
from jb_drf_billing.conf import get_app_slug, get_providers_settings, resolve_model
|
|
13
|
+
from jb_drf_billing.services.entitlements import replace_subscription_grants
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RevenueCatAdapter(BillingProviderAdapter):
|
|
17
|
+
provider_name = "revenuecat"
|
|
18
|
+
|
|
19
|
+
def __init__(self):
|
|
20
|
+
self.provider_cfg = (get_providers_settings() or {}).get("revenuecat", {}) or {}
|
|
21
|
+
|
|
22
|
+
def build_app_user_id(self, user, *, app_slug: str | None = None) -> str:
|
|
23
|
+
return f"{app_slug or get_app_slug()}:user:{user.id}"
|
|
24
|
+
|
|
25
|
+
def parse_app_user_id(self, app_user_id: str | None):
|
|
26
|
+
if not app_user_id or not isinstance(app_user_id, str):
|
|
27
|
+
return None, None
|
|
28
|
+
parts = app_user_id.split(":")
|
|
29
|
+
if len(parts) >= 3 and parts[-2] == "user":
|
|
30
|
+
try:
|
|
31
|
+
return ":".join(parts[:-2]), int(parts[-1])
|
|
32
|
+
except ValueError:
|
|
33
|
+
return None, None
|
|
34
|
+
return None, None
|
|
35
|
+
|
|
36
|
+
def _parse_dt(self, value: Any):
|
|
37
|
+
if value in (None, "", 0):
|
|
38
|
+
return None
|
|
39
|
+
if isinstance(value, (int, float)):
|
|
40
|
+
# RevenueCat often sends *_ms fields
|
|
41
|
+
if value > 10_000_000_000:
|
|
42
|
+
return datetime.fromtimestamp(value / 1000, tz=dt_timezone.utc)
|
|
43
|
+
return datetime.fromtimestamp(value, tz=dt_timezone.utc)
|
|
44
|
+
if isinstance(value, str):
|
|
45
|
+
candidate = value.strip()
|
|
46
|
+
if not candidate:
|
|
47
|
+
return None
|
|
48
|
+
if candidate.endswith("Z"):
|
|
49
|
+
candidate = candidate.replace("Z", "+00:00")
|
|
50
|
+
try:
|
|
51
|
+
dt = datetime.fromisoformat(candidate)
|
|
52
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=dt_timezone.utc)
|
|
53
|
+
except ValueError:
|
|
54
|
+
return None
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def _infer_env(self, data: dict[str, Any]):
|
|
58
|
+
env = (data.get("environment") or "").lower()
|
|
59
|
+
if env in {"sandbox", "production"}:
|
|
60
|
+
return env
|
|
61
|
+
if data.get("is_sandbox") is True:
|
|
62
|
+
return "sandbox"
|
|
63
|
+
return "production"
|
|
64
|
+
|
|
65
|
+
def _infer_status(self, *, expires_at, unsubscribed_at=None, event_type: str | None = None):
|
|
66
|
+
now = timezone.now()
|
|
67
|
+
event_type_norm = (event_type or "").upper()
|
|
68
|
+
if event_type_norm in {"CANCELLATION", "EXPIRATION", "SUBSCRIPTION_EXPIRED"}:
|
|
69
|
+
if expires_at and expires_at > now:
|
|
70
|
+
return "canceled"
|
|
71
|
+
return "expired"
|
|
72
|
+
if unsubscribed_at and expires_at and expires_at > now:
|
|
73
|
+
return "canceled"
|
|
74
|
+
if expires_at and expires_at <= now:
|
|
75
|
+
return "expired"
|
|
76
|
+
return "active"
|
|
77
|
+
|
|
78
|
+
def _payload_hash(self, payload: dict[str, Any]) -> str:
|
|
79
|
+
try:
|
|
80
|
+
serialized = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
|
|
81
|
+
except TypeError:
|
|
82
|
+
serialized = json.dumps(str(payload))
|
|
83
|
+
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
|
84
|
+
|
|
85
|
+
def _event_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
86
|
+
event = payload.get("event") if isinstance(payload, dict) else None
|
|
87
|
+
return event if isinstance(event, dict) else (payload if isinstance(payload, dict) else {})
|
|
88
|
+
|
|
89
|
+
def _validate_webhook_secret(self, headers):
|
|
90
|
+
expected = self.provider_cfg.get("WEBHOOK_SECRET")
|
|
91
|
+
if not expected:
|
|
92
|
+
return True
|
|
93
|
+
header_name = str(self.provider_cfg.get("WEBHOOK_AUTH_HEADER") or "authorization").lower()
|
|
94
|
+
got = None
|
|
95
|
+
for key, value in (headers or {}).items():
|
|
96
|
+
if str(key).lower() == header_name:
|
|
97
|
+
got = value
|
|
98
|
+
break
|
|
99
|
+
if got is None:
|
|
100
|
+
return False
|
|
101
|
+
got_str = str(got).strip()
|
|
102
|
+
if got_str.lower().startswith("bearer "):
|
|
103
|
+
got_str = got_str[7:].strip()
|
|
104
|
+
return got_str == str(expected).strip()
|
|
105
|
+
|
|
106
|
+
def _resolve_user_from_app_user_id(self, app_user_id: str | None):
|
|
107
|
+
_, user_id = self.parse_app_user_id(app_user_id)
|
|
108
|
+
if not user_id:
|
|
109
|
+
return None
|
|
110
|
+
User = get_user_model()
|
|
111
|
+
return User.objects.filter(id=user_id).first()
|
|
112
|
+
|
|
113
|
+
def _get_or_create_billing_customer(self, *, user, app_user_id=None, aliases=None):
|
|
114
|
+
BillingCustomer = resolve_model("BILLING_CUSTOMER_MODEL")
|
|
115
|
+
customer, _ = BillingCustomer.objects.get_or_create(user=user, defaults={"provider_customer_ids": {}})
|
|
116
|
+
provider_ids = dict(customer.provider_customer_ids or {})
|
|
117
|
+
rc_meta = dict(provider_ids.get("revenuecat") or {})
|
|
118
|
+
if app_user_id:
|
|
119
|
+
rc_meta["app_user_id"] = app_user_id
|
|
120
|
+
if aliases:
|
|
121
|
+
rc_meta["aliases"] = sorted({str(a) for a in aliases if a})
|
|
122
|
+
if rc_meta:
|
|
123
|
+
provider_ids["revenuecat"] = rc_meta
|
|
124
|
+
customer.provider_customer_ids = provider_ids
|
|
125
|
+
customer.save(update_fields=["provider_customer_ids", "modified"])
|
|
126
|
+
return customer
|
|
127
|
+
|
|
128
|
+
def _resolve_plan_price_by_product_id(self, product_id: str | None):
|
|
129
|
+
if not product_id:
|
|
130
|
+
return None
|
|
131
|
+
PlanPrice = resolve_model("PLAN_PRICE_MODEL")
|
|
132
|
+
return PlanPrice.objects.select_related("plan", "plan__app").filter(revenuecat_product_id=product_id).first()
|
|
133
|
+
|
|
134
|
+
def _upsert_subscription_from_data(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
billing_customer,
|
|
138
|
+
plan_price,
|
|
139
|
+
app_user_id,
|
|
140
|
+
product_id,
|
|
141
|
+
event_type=None,
|
|
142
|
+
source_data: dict[str, Any],
|
|
143
|
+
):
|
|
144
|
+
Subscription = resolve_model("SUBSCRIPTION_MODEL")
|
|
145
|
+
provider_subscription_id = (
|
|
146
|
+
source_data.get("original_transaction_id")
|
|
147
|
+
or source_data.get("transaction_id")
|
|
148
|
+
or source_data.get("id")
|
|
149
|
+
or f"{app_user_id}:{product_id}"
|
|
150
|
+
)
|
|
151
|
+
expires_at = self._parse_dt(
|
|
152
|
+
source_data.get("expires_at_ms")
|
|
153
|
+
or source_data.get("expiration_at_ms")
|
|
154
|
+
or source_data.get("expires_date")
|
|
155
|
+
or source_data.get("expires_date_ms")
|
|
156
|
+
)
|
|
157
|
+
purchased_at = self._parse_dt(
|
|
158
|
+
source_data.get("purchased_at_ms")
|
|
159
|
+
or source_data.get("purchase_date_ms")
|
|
160
|
+
or source_data.get("purchased_at")
|
|
161
|
+
or source_data.get("purchase_date")
|
|
162
|
+
)
|
|
163
|
+
unsubscribed_at = self._parse_dt(source_data.get("unsubscribe_detected_at") or source_data.get("canceled_at_ms"))
|
|
164
|
+
status = self._infer_status(expires_at=expires_at, unsubscribed_at=unsubscribed_at, event_type=event_type)
|
|
165
|
+
environment = self._infer_env(source_data)
|
|
166
|
+
defaults = {
|
|
167
|
+
"billing_customer": billing_customer,
|
|
168
|
+
"plan": plan_price.plan,
|
|
169
|
+
"plan_price": plan_price,
|
|
170
|
+
"provider_customer_id": app_user_id,
|
|
171
|
+
"status": status,
|
|
172
|
+
"environment": environment,
|
|
173
|
+
"current_period_start": purchased_at,
|
|
174
|
+
"current_period_end": expires_at,
|
|
175
|
+
"cancel_at_period_end": status == "canceled",
|
|
176
|
+
"canceled_at": unsubscribed_at,
|
|
177
|
+
"raw_snapshot": source_data,
|
|
178
|
+
}
|
|
179
|
+
subscription, created = Subscription.objects.update_or_create(
|
|
180
|
+
provider="REVENUECAT",
|
|
181
|
+
provider_subscription_id=str(provider_subscription_id),
|
|
182
|
+
defaults=defaults,
|
|
183
|
+
)
|
|
184
|
+
self._maybe_mark_trial_consumed(billing_customer, source_data)
|
|
185
|
+
return subscription, created
|
|
186
|
+
|
|
187
|
+
def _maybe_mark_trial_consumed(self, billing_customer, source_data):
|
|
188
|
+
"""Mark BillingCustomer.metadata['trial_consumed']=True when RevenueCat
|
|
189
|
+
reports that the introductory offer (trial) has been consumed.
|
|
190
|
+
|
|
191
|
+
This is read by `get_billing_status` to avoid offering the trial UI
|
|
192
|
+
to users who have already consumed it via the store.
|
|
193
|
+
"""
|
|
194
|
+
if not billing_customer or not isinstance(source_data, dict):
|
|
195
|
+
return
|
|
196
|
+
period_type = str(source_data.get("period_type") or "").upper()
|
|
197
|
+
intro_consumed = bool(source_data.get("intro_price_consumed"))
|
|
198
|
+
if period_type != "TRIAL" and not intro_consumed:
|
|
199
|
+
return
|
|
200
|
+
metadata = dict(billing_customer.metadata or {})
|
|
201
|
+
if metadata.get("trial_consumed"):
|
|
202
|
+
return
|
|
203
|
+
metadata["trial_consumed"] = True
|
|
204
|
+
billing_customer.metadata = metadata
|
|
205
|
+
billing_customer.save(update_fields=["metadata", "modified"])
|
|
206
|
+
|
|
207
|
+
def _apply_grants_for_subscription(self, subscription, *, user, event_data=None):
|
|
208
|
+
scope_type = "USER"
|
|
209
|
+
profile = None
|
|
210
|
+
# Future: parse profile assignment from custom metadata.
|
|
211
|
+
return replace_subscription_grants(
|
|
212
|
+
subscription=subscription,
|
|
213
|
+
scope_type=scope_type,
|
|
214
|
+
user=user,
|
|
215
|
+
profile=profile,
|
|
216
|
+
app_slug=getattr(subscription.plan.app, "slug", None),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def _process_revenuecat_event(self, *, event_data: dict[str, Any]):
|
|
220
|
+
event_type = event_data.get("type")
|
|
221
|
+
app_user_id = event_data.get("app_user_id") or event_data.get("original_app_user_id")
|
|
222
|
+
aliases = event_data.get("aliases") or []
|
|
223
|
+
user = self._resolve_user_from_app_user_id(app_user_id)
|
|
224
|
+
if not user:
|
|
225
|
+
return {
|
|
226
|
+
"processed": False,
|
|
227
|
+
"reason": "user_not_found",
|
|
228
|
+
"appUserId": app_user_id,
|
|
229
|
+
"eventType": event_type,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
product_id = event_data.get("product_id")
|
|
233
|
+
plan_price = self._resolve_plan_price_by_product_id(product_id)
|
|
234
|
+
if not plan_price:
|
|
235
|
+
return {
|
|
236
|
+
"processed": False,
|
|
237
|
+
"reason": "plan_price_not_mapped",
|
|
238
|
+
"appUserId": app_user_id,
|
|
239
|
+
"productId": product_id,
|
|
240
|
+
"eventType": event_type,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
billing_customer = self._get_or_create_billing_customer(user=user, app_user_id=app_user_id, aliases=aliases)
|
|
244
|
+
subscription, created = self._upsert_subscription_from_data(
|
|
245
|
+
billing_customer=billing_customer,
|
|
246
|
+
plan_price=plan_price,
|
|
247
|
+
app_user_id=app_user_id,
|
|
248
|
+
product_id=product_id,
|
|
249
|
+
event_type=event_type,
|
|
250
|
+
source_data=event_data,
|
|
251
|
+
)
|
|
252
|
+
created_grants = self._apply_grants_for_subscription(subscription, user=user, event_data=event_data)
|
|
253
|
+
return {
|
|
254
|
+
"processed": True,
|
|
255
|
+
"eventType": event_type,
|
|
256
|
+
"appUserId": app_user_id,
|
|
257
|
+
"productId": product_id,
|
|
258
|
+
"subscriptionId": subscription.id,
|
|
259
|
+
"subscriptionStatus": subscription.status,
|
|
260
|
+
"subscriptionCreated": created,
|
|
261
|
+
"grantsCreated": len(created_grants),
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
def process_webhook(self, payload: dict[str, Any], headers=None):
|
|
265
|
+
if not self._validate_webhook_secret(headers):
|
|
266
|
+
return {"ok": False, "processed": False, "provider": "revenuecat", "reason": "invalid_webhook_secret"}
|
|
267
|
+
|
|
268
|
+
BillingEvent = resolve_model("BILLING_EVENT_MODEL")
|
|
269
|
+
wrapper = payload if isinstance(payload, dict) else {}
|
|
270
|
+
event_data = self._event_payload(wrapper)
|
|
271
|
+
event_id = (
|
|
272
|
+
event_data.get("id")
|
|
273
|
+
or wrapper.get("id")
|
|
274
|
+
or f"{event_data.get('type','UNKNOWN')}:{event_data.get('app_user_id','')}:{event_data.get('event_timestamp_ms') or event_data.get('purchased_at_ms') or timezone.now().timestamp()}"
|
|
275
|
+
)
|
|
276
|
+
payload_hash = self._payload_hash(wrapper)
|
|
277
|
+
|
|
278
|
+
with transaction.atomic():
|
|
279
|
+
event, created = BillingEvent.objects.get_or_create(
|
|
280
|
+
provider="revenuecat",
|
|
281
|
+
external_event_id=str(event_id),
|
|
282
|
+
defaults={
|
|
283
|
+
"event_type": str(event_data.get("type") or wrapper.get("type") or "unknown"),
|
|
284
|
+
"payload_hash": payload_hash,
|
|
285
|
+
"payload": wrapper,
|
|
286
|
+
"status": "received",
|
|
287
|
+
},
|
|
288
|
+
)
|
|
289
|
+
if not created:
|
|
290
|
+
return {
|
|
291
|
+
"ok": True,
|
|
292
|
+
"processed": False,
|
|
293
|
+
"provider": "revenuecat",
|
|
294
|
+
"duplicate": True,
|
|
295
|
+
"eventId": str(event_id),
|
|
296
|
+
"status": event.status,
|
|
297
|
+
}
|
|
298
|
+
try:
|
|
299
|
+
result = self._process_revenuecat_event(event_data=event_data)
|
|
300
|
+
event.status = "processed" if result.get("processed") else "ignored"
|
|
301
|
+
event.processed_at = timezone.now()
|
|
302
|
+
event.payload_hash = payload_hash
|
|
303
|
+
event.payload = wrapper
|
|
304
|
+
event.save(update_fields=["status", "processed_at", "payload_hash", "payload", "modified"])
|
|
305
|
+
return {"ok": True, "provider": "revenuecat", "eventId": str(event_id), **result}
|
|
306
|
+
except Exception as exc:
|
|
307
|
+
event.status = "failed"
|
|
308
|
+
event.error_message = str(exc)
|
|
309
|
+
event.processed_at = timezone.now()
|
|
310
|
+
event.payload_hash = payload_hash
|
|
311
|
+
event.payload = wrapper
|
|
312
|
+
event.save(update_fields=["status", "error_message", "processed_at", "payload_hash", "payload", "modified"])
|
|
313
|
+
raise
|
|
314
|
+
|
|
315
|
+
def _fetch_subscriber(self, app_user_id: str):
|
|
316
|
+
import requests
|
|
317
|
+
|
|
318
|
+
api_key = self.provider_cfg.get("API_KEY")
|
|
319
|
+
if not api_key:
|
|
320
|
+
raise ValueError("RevenueCat API key is not configured.")
|
|
321
|
+
base_url = str(self.provider_cfg.get("API_BASE_URL") or "https://api.revenuecat.com").rstrip("/")
|
|
322
|
+
timeout = int(self.provider_cfg.get("REQUEST_TIMEOUT_SECONDS") or 15)
|
|
323
|
+
url = f"{base_url}/v1/subscribers/{quote(app_user_id, safe='')}"
|
|
324
|
+
response = requests.get(url, headers={"Authorization": f"Bearer {api_key}"}, timeout=timeout)
|
|
325
|
+
response.raise_for_status()
|
|
326
|
+
payload = response.json()
|
|
327
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("subscriber"), dict):
|
|
328
|
+
raise ValueError("Unexpected RevenueCat subscriber payload.")
|
|
329
|
+
return payload
|
|
330
|
+
|
|
331
|
+
def _subscription_records_from_subscriber(self, subscriber_payload: dict[str, Any]):
|
|
332
|
+
subscriber = subscriber_payload.get("subscriber") or {}
|
|
333
|
+
subscriptions = subscriber.get("subscriptions") or {}
|
|
334
|
+
records = []
|
|
335
|
+
for product_id, sub_data in subscriptions.items():
|
|
336
|
+
if not isinstance(sub_data, dict):
|
|
337
|
+
continue
|
|
338
|
+
data = dict(sub_data)
|
|
339
|
+
data.setdefault("product_id", product_id)
|
|
340
|
+
records.append(data)
|
|
341
|
+
# Most recent first if we can infer
|
|
342
|
+
records.sort(
|
|
343
|
+
key=lambda item: (
|
|
344
|
+
self._parse_dt(item.get("purchase_date") or item.get("purchased_at") or item.get("purchase_date_ms"))
|
|
345
|
+
or datetime(1970, 1, 1, tzinfo=dt_timezone.utc)
|
|
346
|
+
),
|
|
347
|
+
reverse=True,
|
|
348
|
+
)
|
|
349
|
+
return subscriber, records
|
|
350
|
+
|
|
351
|
+
@transaction.atomic
|
|
352
|
+
def sync_customer(self, *, user, app_user_id=None, platform=None):
|
|
353
|
+
resolved_app_user_id = app_user_id or self.build_app_user_id(user)
|
|
354
|
+
payload = self._fetch_subscriber(resolved_app_user_id)
|
|
355
|
+
subscriber, records = self._subscription_records_from_subscriber(payload)
|
|
356
|
+
aliases = subscriber.get("aliases") or []
|
|
357
|
+
billing_customer = self._get_or_create_billing_customer(user=user, app_user_id=resolved_app_user_id, aliases=aliases)
|
|
358
|
+
|
|
359
|
+
processed = []
|
|
360
|
+
for record in records:
|
|
361
|
+
product_id = record.get("product_id")
|
|
362
|
+
plan_price = self._resolve_plan_price_by_product_id(product_id)
|
|
363
|
+
if not plan_price:
|
|
364
|
+
continue
|
|
365
|
+
subscription, created = self._upsert_subscription_from_data(
|
|
366
|
+
billing_customer=billing_customer,
|
|
367
|
+
plan_price=plan_price,
|
|
368
|
+
app_user_id=resolved_app_user_id,
|
|
369
|
+
product_id=product_id,
|
|
370
|
+
event_type="SYNC",
|
|
371
|
+
source_data=record,
|
|
372
|
+
)
|
|
373
|
+
grants = self._apply_grants_for_subscription(subscription, user=user, event_data=record)
|
|
374
|
+
processed.append(
|
|
375
|
+
{
|
|
376
|
+
"subscriptionId": subscription.id,
|
|
377
|
+
"subscriptionCreated": created,
|
|
378
|
+
"status": subscription.status,
|
|
379
|
+
"productId": product_id,
|
|
380
|
+
"planSlug": subscription.plan.slug,
|
|
381
|
+
"grantsCreated": len(grants),
|
|
382
|
+
}
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
"ok": True,
|
|
387
|
+
"provider": "revenuecat",
|
|
388
|
+
"synced": True,
|
|
389
|
+
"platform": platform,
|
|
390
|
+
"appUserId": resolved_app_user_id,
|
|
391
|
+
"subscriptionsProcessed": processed,
|
|
392
|
+
"subscriberFound": True,
|
|
393
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Stripe billing adapter.
|
|
2
|
+
|
|
3
|
+
Stable stub: signatures are stable and the module imports without Stripe
|
|
4
|
+
credentials so the lib can be installed and tested in projects that have
|
|
5
|
+
not enabled Stripe yet. Methods that perform real I/O raise a clear
|
|
6
|
+
NotImplementedError pointing to the next implementation phase.
|
|
7
|
+
|
|
8
|
+
# TODO[phase-4-stripe]: replace stubs with real Stripe SDK calls.
|
|
9
|
+
# Required:
|
|
10
|
+
# - Checkout Session create (stripe.checkout.Session.create) with
|
|
11
|
+
# metadata {userId, profileId, scopeType, planPriceId}.
|
|
12
|
+
# - Customer Portal Session create.
|
|
13
|
+
# - Webhook signature verify (stripe.Webhook.construct_event) +
|
|
14
|
+
# event dispatch: checkout.session.completed, customer.subscription.
|
|
15
|
+
# {updated,deleted}, invoice.{paid,payment_failed}.
|
|
16
|
+
# - Customer get-or-create via BillingCustomer.provider_customer_ids['stripe'].
|
|
17
|
+
# - Update upon webhook → call replace_subscription_grants() like RevenueCat.
|
|
18
|
+
# See PHASE_4_TODO.md at the repo root for the full checklist.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from jb_drf_billing.adapters.base import BillingProviderAdapter
|
|
24
|
+
from jb_drf_billing.conf import get_providers_settings
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
PENDING_MESSAGE = "Stripe adapter pending — Phase 4 of jb-drf-billing roadmap."
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class StripeBillingAdapter(BillingProviderAdapter):
|
|
31
|
+
provider_name = "stripe"
|
|
32
|
+
|
|
33
|
+
def __init__(self):
|
|
34
|
+
self.provider_cfg = (get_providers_settings() or {}).get("stripe", {}) or {}
|
|
35
|
+
|
|
36
|
+
# ----- Catalog / config -------------------------------------------------
|
|
37
|
+
|
|
38
|
+
def is_configured(self) -> bool:
|
|
39
|
+
return bool(self.provider_cfg.get("API_KEY"))
|
|
40
|
+
|
|
41
|
+
# ----- Checkout / portal ------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def create_checkout_session(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
user: Any,
|
|
47
|
+
plan_price: Any,
|
|
48
|
+
scope_type: str,
|
|
49
|
+
profile: Any = None,
|
|
50
|
+
success_url: str,
|
|
51
|
+
cancel_url: str,
|
|
52
|
+
metadata: dict | None = None,
|
|
53
|
+
) -> dict:
|
|
54
|
+
"""Stub: returns a sentinel URL so callers can integrate end-to-end
|
|
55
|
+
without real Stripe credentials. Will be replaced in Phase 4.
|
|
56
|
+
"""
|
|
57
|
+
return {
|
|
58
|
+
"ok": False,
|
|
59
|
+
"provider": "stripe",
|
|
60
|
+
"configured": self.is_configured(),
|
|
61
|
+
"checkoutUrl": "stub://stripe-not-configured",
|
|
62
|
+
"sessionId": None,
|
|
63
|
+
"message": PENDING_MESSAGE,
|
|
64
|
+
"metadata": metadata or {},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
def create_portal_session(self, *, user: Any, return_url: str | None = None) -> dict:
|
|
68
|
+
return {
|
|
69
|
+
"ok": False,
|
|
70
|
+
"provider": "stripe",
|
|
71
|
+
"configured": self.is_configured(),
|
|
72
|
+
"portalUrl": "stub://stripe-not-configured",
|
|
73
|
+
"message": PENDING_MESSAGE,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
def change_plan(self, *, user: Any, plan_price: Any, scope_type: str, profile: Any = None) -> dict:
|
|
77
|
+
return {
|
|
78
|
+
"ok": False,
|
|
79
|
+
"provider": "stripe",
|
|
80
|
+
"configured": self.is_configured(),
|
|
81
|
+
"message": PENDING_MESSAGE,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# ----- Provider parity --------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def sync_customer(self, *args, **kwargs):
|
|
87
|
+
raise NotImplementedError(PENDING_MESSAGE)
|
|
88
|
+
|
|
89
|
+
def process_webhook(self, payload, headers=None):
|
|
90
|
+
return {
|
|
91
|
+
"ok": True,
|
|
92
|
+
"processed": False,
|
|
93
|
+
"provider": "stripe",
|
|
94
|
+
"configured": self.is_configured(),
|
|
95
|
+
"message": PENDING_MESSAGE,
|
|
96
|
+
}
|