stapel-billing 0.4.1__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 (46) hide show
  1. stapel_billing-0.4.1/LICENSE +21 -0
  2. stapel_billing-0.4.1/PKG-INFO +58 -0
  3. stapel_billing-0.4.1/README.md +38 -0
  4. stapel_billing-0.4.1/__init__.py +49 -0
  5. stapel_billing-0.4.1/actions.py +23 -0
  6. stapel_billing-0.4.1/admin.py +42 -0
  7. stapel_billing-0.4.1/apps.py +17 -0
  8. stapel_billing-0.4.1/catalog.py +161 -0
  9. stapel_billing-0.4.1/conf.py +49 -0
  10. stapel_billing-0.4.1/conftest.py +108 -0
  11. stapel_billing-0.4.1/dto.py +145 -0
  12. stapel_billing-0.4.1/errors.py +40 -0
  13. stapel_billing-0.4.1/gdpr.py +70 -0
  14. stapel_billing-0.4.1/migrations/0001_initial.py +91 -0
  15. stapel_billing-0.4.1/migrations/__init__.py +0 -0
  16. stapel_billing-0.4.1/models.py +145 -0
  17. stapel_billing-0.4.1/providers/__init__.py +8 -0
  18. stapel_billing-0.4.1/providers/base.py +62 -0
  19. stapel_billing-0.4.1/providers/stripe.py +147 -0
  20. stapel_billing-0.4.1/py.typed +0 -0
  21. stapel_billing-0.4.1/pyproject.toml +46 -0
  22. stapel_billing-0.4.1/schemas/consumes/user.deleted.json +13 -0
  23. stapel_billing-0.4.1/schemas/consumes/user.deletion_initiated.json +13 -0
  24. stapel_billing-0.4.1/schemas/emits/payment.completed.json +17 -0
  25. stapel_billing-0.4.1/schemas/emits/subscription.changed.json +14 -0
  26. stapel_billing-0.4.1/serializers.py +118 -0
  27. stapel_billing-0.4.1/services.py +381 -0
  28. stapel_billing-0.4.1/setup.cfg +4 -0
  29. stapel_billing-0.4.1/stapel_billing.egg-info/PKG-INFO +58 -0
  30. stapel_billing-0.4.1/stapel_billing.egg-info/SOURCES.txt +80 -0
  31. stapel_billing-0.4.1/stapel_billing.egg-info/dependency_links.txt +1 -0
  32. stapel_billing-0.4.1/stapel_billing.egg-info/requires.txt +7 -0
  33. stapel_billing-0.4.1/stapel_billing.egg-info/top_level.txt +1 -0
  34. stapel_billing-0.4.1/tests/__init__.py +0 -0
  35. stapel_billing-0.4.1/tests/test_api.py +139 -0
  36. stapel_billing-0.4.1/tests/test_endpoints.py +392 -0
  37. stapel_billing-0.4.1/tests/test_gdpr_and_misc.py +146 -0
  38. stapel_billing-0.4.1/tests/test_models.py +13 -0
  39. stapel_billing-0.4.1/tests/test_providers.py +319 -0
  40. stapel_billing-0.4.1/tests/test_public_api.py +60 -0
  41. stapel_billing-0.4.1/tests/test_serializer_seams.py +46 -0
  42. stapel_billing-0.4.1/tests/test_stripe_provider.py +167 -0
  43. stapel_billing-0.4.1/tests/test_webhooks.py +315 -0
  44. stapel_billing-0.4.1/tests/urls.py +5 -0
  45. stapel_billing-0.4.1/urls.py +39 -0
  46. stapel_billing-0.4.1/views.py +448 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 usestapel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: stapel-billing
3
+ Version: 0.4.1
4
+ Summary: Billing and payments Django app for the Stapel framework
5
+ License: MIT
6
+ Keywords: django,stapel,billing,payments,stripe
7
+ Classifier: Framework :: Django
8
+ Classifier: Framework :: Django :: 5.2
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: stapel-core<0.4,>=0.3.0
15
+ Provides-Extra: stripe
16
+ Requires-Dist: stripe<12,>=8.0; extra == "stripe"
17
+ Provides-Extra: all
18
+ Requires-Dist: stapel-billing[stripe]; extra == "all"
19
+ Dynamic: license-file
20
+
21
+ # stapel-billing
22
+
23
+ [![CI](https://github.com/usestapel/stapel-billing/actions/workflows/ci.yml/badge.svg)](https://github.com/usestapel/stapel-billing/actions/workflows/ci.yml)
24
+ [![codecov](https://codecov.io/gh/usestapel/stapel-billing/graph/badge.svg)](https://codecov.io/gh/usestapel/stapel-billing)
25
+
26
+ > Billing — Stripe subscriptions, credit wallets, transaction history
27
+
28
+ Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps for building production-grade platforms.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install stapel-billing
34
+ ```
35
+
36
+ ## Quick start
37
+
38
+ ```python
39
+ # settings.py
40
+ INSTALLED_APPS = [
41
+ ...
42
+ 'stapel_billing',
43
+ ]
44
+ ```
45
+
46
+ ## Bus events
47
+
48
+ ### Emits
49
+ | `payment.completed` | [schema](schemas/emits/payment.completed.json) | A payment transaction completed successfully. |
50
+ | `subscription.changed` | [schema](schemas/emits/subscription.changed.json) | User subscription plan or status changed. |
51
+
52
+ ### Consumes
53
+ | `user.deleted` | [schema](schemas/consumes/user.deleted.json) |
54
+ | `user.deletion_initiated` | [schema](schemas/consumes/user.deletion_initiated.json) |
55
+
56
+ ## License
57
+
58
+ MIT — see [LICENSE](LICENSE)
@@ -0,0 +1,38 @@
1
+ # stapel-billing
2
+
3
+ [![CI](https://github.com/usestapel/stapel-billing/actions/workflows/ci.yml/badge.svg)](https://github.com/usestapel/stapel-billing/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/usestapel/stapel-billing/graph/badge.svg)](https://codecov.io/gh/usestapel/stapel-billing)
5
+
6
+ > Billing — Stripe subscriptions, credit wallets, transaction history
7
+
8
+ Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps for building production-grade platforms.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ pip install stapel-billing
14
+ ```
15
+
16
+ ## Quick start
17
+
18
+ ```python
19
+ # settings.py
20
+ INSTALLED_APPS = [
21
+ ...
22
+ 'stapel_billing',
23
+ ]
24
+ ```
25
+
26
+ ## Bus events
27
+
28
+ ### Emits
29
+ | `payment.completed` | [schema](schemas/emits/payment.completed.json) | A payment transaction completed successfully. |
30
+ | `subscription.changed` | [schema](schemas/emits/subscription.changed.json) | User subscription plan or status changed. |
31
+
32
+ ### Consumes
33
+ | `user.deleted` | [schema](schemas/consumes/user.deleted.json) |
34
+ | `user.deletion_initiated` | [schema](schemas/consumes/user.deletion_initiated.json) |
35
+
36
+ ## License
37
+
38
+ MIT — see [LICENSE](LICENSE)
@@ -0,0 +1,49 @@
1
+ """stapel-billing — wallets, credit ledger and pluggable payment providers.
2
+
3
+ Public API (lazily resolved, PEP 562 — importing this package pulls in
4
+ no Django code until an attribute is actually accessed):
5
+
6
+ billing_settings — the ``STAPEL_BILLING`` settings namespace
7
+ credit — add credits to a user's wallet
8
+ debit — deduct credits (raises InsufficientCreditsError)
9
+ get_provider — instantiate the configured PaymentProvider
10
+ PaymentProvider — base class for custom payment backends
11
+ InsufficientCreditsError — raised by ``debit`` on insufficient balance
12
+ """
13
+
14
+ __all__ = [
15
+ "InsufficientCreditsError",
16
+ "PaymentProvider",
17
+ "billing_settings",
18
+ "credit",
19
+ "debit",
20
+ "get_provider",
21
+ ]
22
+
23
+ # name -> (relative module, attribute)
24
+ _EXPORTS = {
25
+ "billing_settings": (".conf", "billing_settings"),
26
+ "credit": (".services", "credit"),
27
+ "debit": (".services", "debit"),
28
+ "get_provider": (".services", "get_provider"),
29
+ "InsufficientCreditsError": (".services", "InsufficientCreditsError"),
30
+ "PaymentProvider": (".providers.base", "PaymentProvider"),
31
+ }
32
+
33
+
34
+ def __getattr__(name):
35
+ try:
36
+ module_path, attr = _EXPORTS[name]
37
+ except KeyError:
38
+ raise AttributeError(
39
+ f"module {__name__!r} has no attribute {name!r}"
40
+ ) from None
41
+ from importlib import import_module
42
+
43
+ value = getattr(import_module(module_path, __name__), attr)
44
+ globals()[name] = value # cache: subsequent lookups skip __getattr__
45
+ return value
46
+
47
+
48
+ def __dir__():
49
+ return sorted(set(globals()) | set(__all__))
@@ -0,0 +1,23 @@
1
+ """Action subscriptions of the billing module.
2
+
3
+ Handlers must be idempotent: delivery is at-least-once (outbox retries,
4
+ broker redelivery).
5
+ """
6
+ import logging
7
+
8
+ from stapel_core.comm import on_action
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @on_action("user.deleted")
14
+ def handle_user_deleted(event):
15
+ """Erase this module's PII when an account deletion is executed."""
16
+ from .gdpr import BillingGDPRProvider
17
+
18
+ user_id = event.payload.get("user_id")
19
+ if not user_id:
20
+ logger.error("user.deleted event without user_id: %s", event.event_id)
21
+ return
22
+ BillingGDPRProvider().delete(user_id)
23
+ logger.info("billing data erased for deleted user %s", user_id)
@@ -0,0 +1,42 @@
1
+ from django.contrib import admin
2
+
3
+ from .models import StripeWebhookEvent, Subscription, Transaction, Wallet
4
+
5
+
6
+ @admin.register(Wallet)
7
+ class WalletAdmin(admin.ModelAdmin):
8
+ list_display = ["user", "balance", "currency", "auto_recharge_enabled", "updated_at"]
9
+ search_fields = ["user__email", "user__id"]
10
+ readonly_fields = ["id", "created_at", "updated_at"]
11
+
12
+
13
+ @admin.register(Transaction)
14
+ class TransactionAdmin(admin.ModelAdmin):
15
+ list_display = ["wallet", "type", "credits_delta", "balance_after", "created_at"]
16
+ list_filter = ["type"]
17
+ search_fields = ["wallet__user__email", "description"]
18
+ readonly_fields = [
19
+ "id",
20
+ "wallet",
21
+ "type",
22
+ "amount_cents",
23
+ "credits_delta",
24
+ "balance_after",
25
+ "metadata",
26
+ "created_at",
27
+ ]
28
+
29
+
30
+ @admin.register(Subscription)
31
+ class SubscriptionAdmin(admin.ModelAdmin):
32
+ list_display = ["user", "plan", "status", "current_period_end", "cancelled_at"]
33
+ list_filter = ["plan", "status"]
34
+ search_fields = ["user__email", "stripe_subscription_id", "stripe_customer_id"]
35
+
36
+
37
+ @admin.register(StripeWebhookEvent)
38
+ class StripeWebhookEventAdmin(admin.ModelAdmin):
39
+ list_display = ["stripe_event_id", "event_type", "processed_at", "received_at"]
40
+ list_filter = ["event_type"]
41
+ search_fields = ["stripe_event_id"]
42
+ readonly_fields = ["id", "received_at"]
@@ -0,0 +1,17 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class BillingConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "stapel_billing"
7
+ label = 'billing'
8
+ verbose_name = "Stapel Billing"
9
+
10
+ def ready(self):
11
+ from stapel_core.gdpr import gdpr_registry
12
+ from .gdpr import BillingGDPRProvider
13
+ gdpr_registry.register(BillingGDPRProvider())
14
+
15
+ # Action subscriptions (in-process in a monolith, bus consumer in
16
+ # microservices — same code, transport chosen by STAPEL_COMM).
17
+ from . import actions # noqa: F401
@@ -0,0 +1,161 @@
1
+ """
2
+ Credit packages and subscription plan catalogue.
3
+
4
+ The lists below are *defaults*. Host projects override them through the
5
+ ``STAPEL_BILLING`` settings namespace without forking::
6
+
7
+ STAPEL_BILLING = {
8
+ "CREDIT_PACKAGES": [
9
+ {"slug": "mini", "name": "Mini", "credits": 100, "price_cents": 200},
10
+ ],
11
+ "PLANS": [
12
+ {"slug": "free", "name": "Free", "price_cents": 0,
13
+ "monthly_credits_included": 0, "storage_limit_bytes": 0,
14
+ "description": "..."},
15
+ ],
16
+ }
17
+
18
+ Entries may be dicts (converted to the dataclasses below) or dataclass
19
+ instances. The module-level names ``CREDIT_PACKAGES`` / ``PLANS`` /
20
+ ``CREDIT_PACKAGES_BY_SLUG`` / ``PLANS_BY_SLUG`` keep working — they are
21
+ lazy views that re-read the configuration on every access.
22
+ """
23
+
24
+ from collections.abc import Mapping, Sequence
25
+ from dataclasses import dataclass
26
+ from typing import Callable, Iterator
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class CreditPackage:
31
+ slug: str
32
+ name: str
33
+ credits: int
34
+ price_cents: int
35
+ currency: str = "USD"
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class PlanCatalogEntry:
40
+ slug: str
41
+ name: str
42
+ price_cents: int
43
+ monthly_credits_included: int
44
+ storage_limit_bytes: int
45
+ description: str
46
+ currency: str = "USD"
47
+
48
+
49
+ DEFAULT_CREDIT_PACKAGES = [
50
+ CreditPackage("starter", "Starter", credits=500, price_cents=500),
51
+ CreditPackage("standard", "Standard", credits=2200, price_cents=2000),
52
+ CreditPackage("bulk", "Bulk", credits=6000, price_cents=5000),
53
+ ]
54
+
55
+ DEFAULT_PLANS = [
56
+ PlanCatalogEntry(
57
+ slug="free",
58
+ name="Free",
59
+ price_cents=0,
60
+ monthly_credits_included=0,
61
+ storage_limit_bytes=5 * 1024 * 1024 * 1024,
62
+ description="5h upload per month, Fast ASR only, community support.",
63
+ ),
64
+ PlanCatalogEntry(
65
+ slug="pro",
66
+ name="Pro",
67
+ price_cents=1500,
68
+ monthly_credits_included=300,
69
+ storage_limit_bytes=100 * 1024 * 1024 * 1024,
70
+ description="Unlimited upload, Accurate ASR, 300 credits/mo.",
71
+ ),
72
+ PlanCatalogEntry(
73
+ slug="team",
74
+ name="Team",
75
+ price_cents=2500,
76
+ monthly_credits_included=600,
77
+ storage_limit_bytes=500 * 1024 * 1024 * 1024,
78
+ description="Pro plus team roles, 600 credits/mo, SSO.",
79
+ ),
80
+ PlanCatalogEntry(
81
+ slug="enterprise",
82
+ name="Enterprise",
83
+ price_cents=0,
84
+ monthly_credits_included=0,
85
+ storage_limit_bytes=10 * 1024 * 1024 * 1024 * 1024,
86
+ description="Custom pricing.",
87
+ ),
88
+ ]
89
+
90
+
91
+ def _coerce(entry, cls):
92
+ if isinstance(entry, cls):
93
+ return entry
94
+ if isinstance(entry, Mapping):
95
+ return cls(**entry)
96
+ raise TypeError(
97
+ f"catalog entries must be {cls.__name__} instances or dicts, got {entry!r}"
98
+ )
99
+
100
+
101
+ def get_credit_packages() -> list[CreditPackage]:
102
+ """Resolve the credit-package catalogue through STAPEL_BILLING config."""
103
+ from .conf import billing_settings
104
+
105
+ raw = billing_settings.CREDIT_PACKAGES
106
+ return [_coerce(e, CreditPackage) for e in raw]
107
+
108
+
109
+ def get_plans() -> list[PlanCatalogEntry]:
110
+ """Resolve the plan catalogue through STAPEL_BILLING config."""
111
+ from .conf import billing_settings
112
+
113
+ raw = billing_settings.PLANS
114
+ return [_coerce(e, PlanCatalogEntry) for e in raw]
115
+
116
+
117
+ class _LazyCatalogList(Sequence):
118
+ """Sequence view over a loader — re-reads configuration on access."""
119
+
120
+ def __init__(self, loader: Callable[[], list]) -> None:
121
+ self._loader = loader
122
+
123
+ def __getitem__(self, index):
124
+ return self._loader()[index]
125
+
126
+ def __len__(self) -> int:
127
+ return len(self._loader())
128
+
129
+ def __iter__(self) -> Iterator:
130
+ return iter(self._loader())
131
+
132
+ def __repr__(self) -> str:
133
+ return repr(self._loader())
134
+
135
+
136
+ class _LazyBySlug(Mapping):
137
+ """Mapping view keyed by slug — re-reads configuration on access."""
138
+
139
+ def __init__(self, loader: Callable[[], list]) -> None:
140
+ self._loader = loader
141
+
142
+ def _mapping(self) -> dict:
143
+ return {entry.slug: entry for entry in self._loader()}
144
+
145
+ def __getitem__(self, key):
146
+ return self._mapping()[key]
147
+
148
+ def __iter__(self) -> Iterator:
149
+ return iter(self._mapping())
150
+
151
+ def __len__(self) -> int:
152
+ return len(self._mapping())
153
+
154
+ def __repr__(self) -> str:
155
+ return repr(self._mapping())
156
+
157
+
158
+ CREDIT_PACKAGES = _LazyCatalogList(get_credit_packages)
159
+ PLANS = _LazyCatalogList(get_plans)
160
+ CREDIT_PACKAGES_BY_SLUG = _LazyBySlug(get_credit_packages)
161
+ PLANS_BY_SLUG = _LazyBySlug(get_plans)
@@ -0,0 +1,49 @@
1
+ """Settings namespace for stapel-billing.
2
+
3
+ All configuration is read through ``billing_settings`` (lazily, at call
4
+ time) instead of module-level ``os.getenv`` — so tests and host projects
5
+ can override any key via ``settings.STAPEL_BILLING``, a flat Django
6
+ setting of the same name, or an environment variable::
7
+
8
+ STAPEL_BILLING = {
9
+ "PAYMENT_PROVIDER": "myproject.billing.MyProvider",
10
+ "STRIPE_SECRET_KEY": "sk_live_...",
11
+ "CREDIT_PACKAGES": [
12
+ {"slug": "mini", "name": "Mini", "credits": 100, "price_cents": 200},
13
+ ],
14
+ "PLANS": [...],
15
+ }
16
+
17
+ ``PAYMENT_PROVIDER`` is resolved with import_string — the dotted-path
18
+ escape hatch that makes the payment backend swappable without forking.
19
+ """
20
+ from stapel_core.conf import AppSettings
21
+
22
+ from . import catalog as _catalog
23
+
24
+ billing_settings = AppSettings(
25
+ "STAPEL_BILLING",
26
+ defaults={
27
+ # Dotted path to a stapel_billing.providers.base.PaymentProvider subclass.
28
+ "PAYMENT_PROVIDER": "stapel_billing.providers.stripe.StripeProvider",
29
+ # Read lazily at call time (never frozen at import).
30
+ "STRIPE_SECRET_KEY": "",
31
+ "STRIPE_WEBHOOK_SECRET": "",
32
+ # Catalog overrides: lists of dicts (converted to the catalog
33
+ # dataclasses) or dataclass instances. Defaults live in catalog.py.
34
+ "CREDIT_PACKAGES": _catalog.DEFAULT_CREDIT_PACKAGES,
35
+ "PLANS": _catalog.DEFAULT_PLANS,
36
+ # Fallback redirect targets for Stripe Checkout / customer portal,
37
+ # used when the request does not carry them. Empty by default;
38
+ # when unset the fallback is derived from the flat FRONTEND_URL
39
+ # setting, and with neither configured the request is rejected
40
+ # with error.400.redirect_url_not_configured (no placeholder
41
+ # domains, ever).
42
+ "CHECKOUT_SUCCESS_URL": "",
43
+ "CHECKOUT_CANCEL_URL": "",
44
+ "PORTAL_RETURN_URL": "",
45
+ },
46
+ import_strings=("PAYMENT_PROVIDER",),
47
+ )
48
+
49
+ __all__ = ["billing_settings"]
@@ -0,0 +1,108 @@
1
+ def pytest_configure(config):
2
+ from django.conf import settings
3
+ if not settings.configured:
4
+ settings.configure(
5
+ SECRET_KEY="test-secret-key-not-for-production",
6
+ INSTALLED_APPS=[
7
+ "django.contrib.contenttypes",
8
+ "django.contrib.auth",
9
+ "django.contrib.sessions",
10
+ "django.contrib.messages",
11
+ # contrib.admin so the ModelAdmin registrations in admin.py
12
+ # are importable (and covered) in tests.
13
+ "django.contrib.admin",
14
+ "stapel_core.django.users",
15
+ "rest_framework",
16
+ "stapel_billing",
17
+ ],
18
+ AUTH_USER_MODEL="users.User",
19
+ DATABASES={
20
+ "default": {
21
+ "ENGINE": "django.db.backends.sqlite3",
22
+ "NAME": ":memory:",
23
+ }
24
+ },
25
+ DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
26
+ USE_TZ=True,
27
+ ROOT_URLCONF="stapel_billing.tests.urls",
28
+ CACHES={
29
+ "default": {
30
+ "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
31
+ }
32
+ },
33
+ # In-memory bus — no Kafka/Redis broker needed
34
+ STAPEL_BUS_BACKEND="stapel_core.bus.backends.memory.MemoryBus",
35
+ # Synchronous in-process action delivery (no outbox table needed)
36
+ # and schema validation ON so emits are checked against
37
+ # schemas/emits/*.json in tests.
38
+ STAPEL_COMM={
39
+ "OUTBOX_ENABLED": False,
40
+ "ACTION_TRANSPORT": "inprocess",
41
+ "VALIDATE_SCHEMAS": True,
42
+ },
43
+ MIDDLEWARE=[
44
+ "django.middleware.common.CommonMiddleware",
45
+ "stapel_core.django.jwt.middleware.ServiceAPIKeyMiddleware",
46
+ ],
47
+ SERVICE_API_KEY="",
48
+ # Checkout/portal redirect fallbacks derive from this when the
49
+ # request and STAPEL_BILLING don't provide URLs.
50
+ FRONTEND_URL="https://front.example",
51
+ # Skip migrations — create tables directly from models
52
+ MIGRATION_MODULES={
53
+ "users": None,
54
+ "billing": None,
55
+ },
56
+ )
57
+ import django
58
+ django.setup()
59
+
60
+ # Register schemas/emits/*.json with the comm registries so emit()
61
+ # payloads are validated against the committed contracts in tests.
62
+ from stapel_core.comm.schemas import autoload_schemas
63
+ autoload_schemas()
64
+
65
+
66
+ import pytest # noqa: E402
67
+
68
+
69
+ @pytest.fixture
70
+ def user(db):
71
+ from django.contrib.auth import get_user_model
72
+ User = get_user_model()
73
+ return User.objects.create_user(
74
+ username="testuser",
75
+ email="testuser@example.com",
76
+ password="testpass123",
77
+ )
78
+
79
+
80
+ @pytest.fixture
81
+ def api_client():
82
+ from rest_framework.test import APIClient
83
+ return APIClient()
84
+
85
+
86
+ @pytest.fixture
87
+ def authed_client(user):
88
+ from rest_framework.test import APIClient
89
+ client = APIClient()
90
+ client.force_authenticate(user=user)
91
+ return client
92
+
93
+
94
+ @pytest.fixture
95
+ def stripe_disabled(settings, monkeypatch):
96
+ """Disable Stripe regardless of the host environment.
97
+
98
+ Keys are read lazily through stapel_billing.conf.billing_settings, so
99
+ overriding the Django settings here actually takes effect at call
100
+ time (they are no longer frozen at import via os.getenv). The env
101
+ vars are cleared too so the AppSettings env fallback cannot leak a
102
+ real key into the test.
103
+ """
104
+ monkeypatch.delenv("STRIPE_SECRET_KEY", raising=False)
105
+ monkeypatch.delenv("STRIPE_WEBHOOK_SECRET", raising=False)
106
+ settings.STRIPE_SECRET_KEY = ""
107
+ settings.STRIPE_WEBHOOK_SECRET = ""
108
+ return settings