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.
Files changed (64) hide show
  1. jb_drf_billing-0.1.0/PKG-INFO +30 -0
  2. jb_drf_billing-0.1.0/README.md +19 -0
  3. jb_drf_billing-0.1.0/jb_drf_billing/__init__.py +1 -0
  4. jb_drf_billing-0.1.0/jb_drf_billing/adapters/__init__.py +3 -0
  5. jb_drf_billing-0.1.0/jb_drf_billing/adapters/base.py +8 -0
  6. jb_drf_billing-0.1.0/jb_drf_billing/adapters/revenuecat.py +393 -0
  7. jb_drf_billing-0.1.0/jb_drf_billing/adapters/stripe.py +96 -0
  8. jb_drf_billing-0.1.0/jb_drf_billing/admin.py +161 -0
  9. jb_drf_billing-0.1.0/jb_drf_billing/apps.py +10 -0
  10. jb_drf_billing-0.1.0/jb_drf_billing/catalog/__init__.py +3 -0
  11. jb_drf_billing-0.1.0/jb_drf_billing/catalog/base.py +3 -0
  12. jb_drf_billing-0.1.0/jb_drf_billing/catalog/db_provider.py +71 -0
  13. jb_drf_billing-0.1.0/jb_drf_billing/catalog/settings_provider.py +12 -0
  14. jb_drf_billing-0.1.0/jb_drf_billing/checks.py +41 -0
  15. jb_drf_billing-0.1.0/jb_drf_billing/conf.py +126 -0
  16. jb_drf_billing-0.1.0/jb_drf_billing/exceptions.py +2 -0
  17. jb_drf_billing-0.1.0/jb_drf_billing/models/__init__.py +1 -0
  18. jb_drf_billing-0.1.0/jb_drf_billing/models/base.py +218 -0
  19. jb_drf_billing-0.1.0/jb_drf_billing/permissions.py +5 -0
  20. jb_drf_billing-0.1.0/jb_drf_billing/policies/__init__.py +2 -0
  21. jb_drf_billing-0.1.0/jb_drf_billing/policies/access.py +9 -0
  22. jb_drf_billing-0.1.0/jb_drf_billing/policies/features.py +3 -0
  23. jb_drf_billing-0.1.0/jb_drf_billing/serializers/__init__.py +3 -0
  24. jb_drf_billing-0.1.0/jb_drf_billing/serializers/access.py +16 -0
  25. jb_drf_billing-0.1.0/jb_drf_billing/serializers/catalog.py +7 -0
  26. jb_drf_billing-0.1.0/jb_drf_billing/serializers/mobile.py +6 -0
  27. jb_drf_billing-0.1.0/jb_drf_billing/serializers/status.py +9 -0
  28. jb_drf_billing-0.1.0/jb_drf_billing/serializers/web.py +16 -0
  29. jb_drf_billing-0.1.0/jb_drf_billing/serializers/webhooks.py +5 -0
  30. jb_drf_billing-0.1.0/jb_drf_billing/services/__init__.py +4 -0
  31. jb_drf_billing-0.1.0/jb_drf_billing/services/access.py +44 -0
  32. jb_drf_billing-0.1.0/jb_drf_billing/services/catalog.py +15 -0
  33. jb_drf_billing-0.1.0/jb_drf_billing/services/entitlements.py +116 -0
  34. jb_drf_billing-0.1.0/jb_drf_billing/services/mobile_sync.py +16 -0
  35. jb_drf_billing-0.1.0/jb_drf_billing/services/status.py +82 -0
  36. jb_drf_billing-0.1.0/jb_drf_billing/services/web_checkout.py +101 -0
  37. jb_drf_billing-0.1.0/jb_drf_billing/services/webhooks.py +20 -0
  38. jb_drf_billing-0.1.0/jb_drf_billing/signals.py +8 -0
  39. jb_drf_billing-0.1.0/jb_drf_billing/tests/__init__.py +0 -0
  40. jb_drf_billing-0.1.0/jb_drf_billing/tests/settings.py +28 -0
  41. jb_drf_billing-0.1.0/jb_drf_billing/tests/test_access.py +1 -0
  42. jb_drf_billing-0.1.0/jb_drf_billing/tests/test_checks.py +10 -0
  43. jb_drf_billing-0.1.0/jb_drf_billing/tests/test_conf.py +13 -0
  44. jb_drf_billing-0.1.0/jb_drf_billing/tests/test_entitlements.py +1 -0
  45. jb_drf_billing-0.1.0/jb_drf_billing/tests/test_status.py +1 -0
  46. jb_drf_billing-0.1.0/jb_drf_billing/tests/test_urls.py +7 -0
  47. jb_drf_billing-0.1.0/jb_drf_billing/tests/test_webhooks.py +1 -0
  48. jb_drf_billing-0.1.0/jb_drf_billing/urls.py +29 -0
  49. jb_drf_billing-0.1.0/jb_drf_billing/utils.py +7 -0
  50. jb_drf_billing-0.1.0/jb_drf_billing/views/__init__.py +7 -0
  51. jb_drf_billing-0.1.0/jb_drf_billing/views/access.py +26 -0
  52. jb_drf_billing-0.1.0/jb_drf_billing/views/catalog.py +21 -0
  53. jb_drf_billing-0.1.0/jb_drf_billing/views/entitlements.py +28 -0
  54. jb_drf_billing-0.1.0/jb_drf_billing/views/mobile.py +29 -0
  55. jb_drf_billing-0.1.0/jb_drf_billing/views/status.py +14 -0
  56. jb_drf_billing-0.1.0/jb_drf_billing/views/web.py +35 -0
  57. jb_drf_billing-0.1.0/jb_drf_billing/views/webhooks.py +26 -0
  58. jb_drf_billing-0.1.0/jb_drf_billing.egg-info/PKG-INFO +30 -0
  59. jb_drf_billing-0.1.0/jb_drf_billing.egg-info/SOURCES.txt +62 -0
  60. jb_drf_billing-0.1.0/jb_drf_billing.egg-info/dependency_links.txt +1 -0
  61. jb_drf_billing-0.1.0/jb_drf_billing.egg-info/requires.txt +4 -0
  62. jb_drf_billing-0.1.0/jb_drf_billing.egg-info/top_level.txt +1 -0
  63. jb_drf_billing-0.1.0/pyproject.toml +16 -0
  64. 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,3 @@
1
+ from .base import BillingProviderAdapter as BillingProviderAdapter
2
+ from .revenuecat import RevenueCatAdapter as RevenueCatAdapter
3
+ from .stripe import StripeBillingAdapter as StripeBillingAdapter
@@ -0,0 +1,8 @@
1
+ class BillingProviderAdapter:
2
+ provider_name = None
3
+
4
+ def sync_customer(self, *args, **kwargs):
5
+ raise NotImplementedError
6
+
7
+ def process_webhook(self, *args, **kwargs):
8
+ raise NotImplementedError
@@ -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
+ }