feat: shop PaymentIntent flow — shop_buy + shop_confirm + stripe_webhook — Chunk 3 of [[project-wallet-shop-expansion]]. Three-endpoint split per the locked Stripe design: webhook is authoritative for fulfillment (resilient to 3DS, browser closes, network drops); sync /shop/confirm is a best-effort UX speedup (fulfills immediately when Stripe.js confirms client-side, no waiting for webhook delivery); both call Purchase.fulfill() which is idempotent — whichever lands first wins, the other becomes a no-op via the status==SUCCEEDED guard. **POST /dashboard/wallet/shop/buy** (form-encoded shop_item_slug): looks up active ShopItem (404 if missing/inactive); enforces max_owned via is_available_for(user) (409 if cap hit, eg already-owned BAND); requires a saved PaymentMethod (402 otherwise — picks most-recent via order_by('-pk').first() per the open-Q note in the scope doc); creates Stripe PaymentIntent (amount=item.price_cents, currency=usd, customer=user.stripe_customer_id, payment_method=pm.stripe_pm_id, automatic_payment_methods={enabled, allow_redirects=never} for in-window 3DS); creates Purchase w. pi.id; backfills pi.metadata.purchase_id via PaymentIntent.modify so the webhook handler can resolve back to the row; returns {client_secret, purchase_id} JSON for Stripe.js confirmCardPayment. **POST /dashboard/wallet/shop/confirm** (form-encoded purchase_id): retrieves PI from Stripe, if status=='succeeded' calls purchase.fulfill(); returns {status} JSON. 404 if the purchase doesn't belong to request.user. Idempotent — re-firing after fulfill is a safe no-op. **POST /stripe/webhook** (csrf_exempt, mounted at root /stripe/webhook so the URL stays stable across app-routing refactors w. Stripe's dashboard config): verifies signature via stripe.Webhook.construct_event against STRIPE_WEBHOOK_SECRET env var (400 on mismatch — Stripe won't retry on 4xx, only 5xx); on payment_intent.succeeded looks up Purchase by metadata.purchase_id w. fall-back to stripe_payment_intent_id (both unique). Unknown event types are no-op 200 (Stripe sends charge.dispute.created etc. + would retry indefinitely on 5xx). New STRIPE_WEBHOOK_SECRET = os.environ.get(...) setting; user swaps it on staging+prod per the live-mode env-var-only decision. TDD — 17 ITs in test_shop_views.py across 3 classes: ShopBuyViewTest (7 cases — login required, success path creates PI + Purchase w. correct shape, PI.create called w. correct args, unknown slug 404, inactive item 404, max_owned 409, no PM 402); ShopConfirmViewTest (5 cases — login required, succeeded PI triggers fulfill, processing PI leaves PENDING, idempotent on already-SUCCEEDED, other user's purchase 404); StripeWebhookViewTest (5 cases — sig mismatch 400, succeeded event triggers fulfill, unknown event type 2xx no-op, duplicate delivery idempotent, unknown purchase_id 2xx no-op). All Stripe API calls mocked via mock.patch('apps.dashboard.views.stripe'). 1208 IT/UT green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-22 00:42:09 -04:00
parent 849ef3c310
commit 410664fb0f
5 changed files with 486 additions and 2 deletions

View File

@@ -0,0 +1,337 @@
"""Shop view ITs — Chunk 3 of the wallet-expansion sprint.
Three endpoints under test:
* `POST /dashboard/wallet/shop/buy` (`shop_buy`) — creates a Stripe
PaymentIntent + a `Purchase` row in PENDING; returns
`{client_secret, purchase_id}` for Stripe.js confirmCardPayment.
* `POST /dashboard/wallet/shop/confirm` (`shop_confirm`) — sync follow-up
after Stripe.js confirms client-side; retrieves the PI from Stripe,
if `status=='succeeded'` calls `Purchase.fulfill()` (idempotent w. the
webhook's parallel call).
* `POST /stripe/webhook` (`stripe_webhook`) — async fulfillment fallback
+ bulletproof for 3DS-completed cards. Verifies signature against
`STRIPE_WEBHOOK_SECRET`; on `payment_intent.succeeded` calls
`Purchase.fulfill()` (same idempotent method as `shop_confirm`).
`apps.dashboard.views.stripe` is mocked across all three. The webhook
view's signature verification is also mocked via
`stripe.Webhook.construct_event`.
"""
from unittest import mock
from django.test import TestCase, override_settings
from apps.lyric.models import PaymentMethod, Purchase, ShopItem, Token, User
def _seed_starting_items():
"""Mirror the seed-migration row shape so each TestCase starts w. a
known catalog (TestCase rolls back the data migration, so the rows
seeded by `0009_seed_shop_items` aren't there during tests)."""
ShopItem.objects.update_or_create(
slug="tithe-1",
defaults={
"name": "Tithe Token", "description": "1 Tithe + 144 Writs",
"icon": "fa-piggy-bank", "badge_text": "",
"price_cents": 100, "granted_token_type": Token.TITHE,
"granted_count": 1, "granted_writs": 144,
"max_owned": None, "display_order": 10, "active": True,
},
)
ShopItem.objects.update_or_create(
slug="band-1",
defaults={
"name": "Wristband", "description": "Admit All Entry",
"icon": "fa-ring", "badge_text": "",
"price_cents": 2000, "granted_token_type": Token.BAND,
"granted_count": 1, "granted_writs": 0,
"max_owned": 1, "display_order": 30, "active": True,
},
)
class ShopBuyViewTest(TestCase):
def setUp(self):
_seed_starting_items()
self.user = User.objects.create(email="buyer@test.io")
self.user.stripe_customer_id = "cus_buyer"
self.user.save()
PaymentMethod.objects.create(
user=self.user, stripe_pm_id="pm_test_4242",
last4="4242", brand="visa",
)
self.client.force_login(self.user)
def test_requires_login(self):
self.client.logout()
response = self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"}
)
self.assertRedirects(
response, "/?next=/dashboard/wallet/shop/buy",
fetch_redirect_response=False,
)
@mock.patch("apps.dashboard.views.stripe")
def test_success_creates_payment_intent_and_purchase(self, mock_stripe):
mock_stripe.PaymentIntent.create.return_value = mock.Mock(
id="pi_test_abc", client_secret="pi_test_abc_secret",
)
response = self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"},
)
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["client_secret"], "pi_test_abc_secret")
purchase = Purchase.objects.get(pk=body["purchase_id"])
self.assertEqual(purchase.user, self.user)
self.assertEqual(purchase.shop_item.slug, "tithe-1")
self.assertEqual(purchase.status, Purchase.PENDING)
self.assertEqual(purchase.stripe_payment_intent_id, "pi_test_abc")
self.assertEqual(purchase.amount_cents, 100)
self.assertEqual(purchase.granted_writs, 144)
@mock.patch("apps.dashboard.views.stripe")
def test_payment_intent_called_with_correct_args(self, mock_stripe):
mock_stripe.PaymentIntent.create.return_value = mock.Mock(
id="pi_a", client_secret="pi_a_secret",
)
self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"},
)
kwargs = mock_stripe.PaymentIntent.create.call_args.kwargs
self.assertEqual(kwargs["amount"], 100)
self.assertEqual(kwargs["currency"], "usd")
self.assertEqual(kwargs["customer"], "cus_buyer")
self.assertEqual(kwargs["payment_method"], "pm_test_4242")
@mock.patch("apps.dashboard.views.stripe")
def test_unknown_item_slug_returns_404(self, mock_stripe):
response = self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "no-such-item"},
)
self.assertEqual(response.status_code, 404)
@mock.patch("apps.dashboard.views.stripe")
def test_inactive_item_returns_404(self, mock_stripe):
item = ShopItem.objects.get(slug="tithe-1")
item.active = False
item.save()
response = self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"},
)
self.assertEqual(response.status_code, 404)
@mock.patch("apps.dashboard.views.stripe")
def test_max_owned_violation_returns_409(self, mock_stripe):
"""User already owns 1 BAND (max_owned=1) → buy refused w. 409."""
Token.objects.create(user=self.user, token_type=Token.BAND)
response = self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "band-1"},
)
self.assertEqual(response.status_code, 409)
# No PaymentIntent created
mock_stripe.PaymentIntent.create.assert_not_called()
@mock.patch("apps.dashboard.views.stripe")
def test_no_payment_method_returns_402(self, mock_stripe):
"""User w. no saved PaymentMethod → 402 Payment Required."""
self.user.payment_methods.all().delete()
response = self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"},
)
self.assertEqual(response.status_code, 402)
mock_stripe.PaymentIntent.create.assert_not_called()
class ShopConfirmViewTest(TestCase):
def setUp(self):
_seed_starting_items()
self.user = User.objects.create(email="confirm@test.io")
self.user.tokens.all().delete()
self.user.refresh_from_db()
self.client.force_login(self.user)
self.tithe = ShopItem.objects.get(slug="tithe-1")
self.purchase = Purchase.objects.create(
user=self.user, shop_item=self.tithe,
stripe_payment_intent_id="pi_conf_1",
amount_cents=100, granted_writs=144,
)
def test_requires_login(self):
self.client.logout()
response = self.client.post(
"/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk},
)
self.assertRedirects(
response, "/?next=/dashboard/wallet/shop/confirm",
fetch_redirect_response=False,
)
@mock.patch("apps.dashboard.views.stripe")
def test_succeeded_pi_triggers_fulfill(self, mock_stripe):
mock_stripe.PaymentIntent.retrieve.return_value = mock.Mock(status="succeeded")
response = self.client.post(
"/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk},
)
self.assertEqual(response.status_code, 200)
self.purchase.refresh_from_db()
self.assertEqual(self.purchase.status, Purchase.SUCCEEDED)
self.assertEqual(
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
)
@mock.patch("apps.dashboard.views.stripe")
def test_pending_pi_does_not_fulfill(self, mock_stripe):
"""Stripe still processing → leave Purchase PENDING for the webhook."""
mock_stripe.PaymentIntent.retrieve.return_value = mock.Mock(status="processing")
self.client.post(
"/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk},
)
self.purchase.refresh_from_db()
self.assertEqual(self.purchase.status, Purchase.PENDING)
self.assertEqual(
self.user.tokens.filter(token_type=Token.TITHE).count(), 0
)
@mock.patch("apps.dashboard.views.stripe")
def test_idempotent_when_already_succeeded(self, mock_stripe):
"""Webhook already fulfilled — confirm endpoint shouldn't double-mint."""
self.purchase.fulfill()
self.assertEqual(self.purchase.status, Purchase.SUCCEEDED)
mock_stripe.PaymentIntent.retrieve.return_value = mock.Mock(status="succeeded")
response = self.client.post(
"/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
)
@mock.patch("apps.dashboard.views.stripe")
def test_other_users_purchase_returns_404(self, mock_stripe):
"""A user trying to confirm someone else's PendingPurchase gets 404."""
other = User.objects.create(email="other@test.io")
other_purchase = Purchase.objects.create(
user=other, shop_item=self.tithe,
stripe_payment_intent_id="pi_other",
amount_cents=100, granted_writs=144,
)
response = self.client.post(
"/dashboard/wallet/shop/confirm", {"purchase_id": other_purchase.pk},
)
self.assertEqual(response.status_code, 404)
@override_settings(STRIPE_WEBHOOK_SECRET="whsec_test_123")
class StripeWebhookViewTest(TestCase):
def setUp(self):
_seed_starting_items()
self.user = User.objects.create(email="webhook@test.io")
self.user.tokens.all().delete()
self.user.refresh_from_db()
self.tithe = ShopItem.objects.get(slug="tithe-1")
self.purchase = Purchase.objects.create(
user=self.user, shop_item=self.tithe,
stripe_payment_intent_id="pi_wh_1",
amount_cents=100, granted_writs=144,
)
@mock.patch("apps.dashboard.views.stripe")
def test_signature_mismatch_returns_400(self, mock_stripe):
mock_stripe.Webhook.construct_event.side_effect = ValueError("bad sig")
response = self.client.post(
"/stripe/webhook",
data=b"{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=0,v1=deadbeef",
)
self.assertEqual(response.status_code, 400)
@mock.patch("apps.dashboard.views.stripe")
def test_payment_intent_succeeded_triggers_fulfill(self, mock_stripe):
# `Mock(intent_obj, ...)` returns a `dict()`-style object that supports
# `event["type"]` AND `event["data"]["object"]["id"]` — match what the
# webhook view will read.
mock_stripe.Webhook.construct_event.return_value = {
"type": "payment_intent.succeeded",
"data": {"object": {
"id": "pi_wh_1",
"metadata": {"purchase_id": str(self.purchase.pk)},
}},
}
response = self.client.post(
"/stripe/webhook", data=b"{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
)
self.assertEqual(response.status_code, 200)
self.purchase.refresh_from_db()
self.assertEqual(self.purchase.status, Purchase.SUCCEEDED)
self.assertEqual(
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
)
@mock.patch("apps.dashboard.views.stripe")
def test_unknown_event_type_is_noop(self, mock_stripe):
"""Stripe sends a lot of event types we don't care about (eg
`charge.dispute.created`). Webhook view should return 2xx so
Stripe doesn't retry, but NOT touch the Purchase."""
mock_stripe.Webhook.construct_event.return_value = {
"type": "charge.dispute.created",
"data": {"object": {"id": "pi_wh_1"}},
}
response = self.client.post(
"/stripe/webhook", data=b"{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
)
self.assertEqual(response.status_code, 200)
self.purchase.refresh_from_db()
self.assertEqual(self.purchase.status, Purchase.PENDING)
@mock.patch("apps.dashboard.views.stripe")
def test_duplicate_delivery_is_idempotent(self, mock_stripe):
"""Stripe may redeliver a webhook (network blip, our 5xx, etc.).
Re-firing the same `payment_intent.succeeded` event must not
double-mint tokens."""
mock_stripe.Webhook.construct_event.return_value = {
"type": "payment_intent.succeeded",
"data": {"object": {
"id": "pi_wh_1",
"metadata": {"purchase_id": str(self.purchase.pk)},
}},
}
self.client.post(
"/stripe/webhook", data=b"{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
)
self.client.post(
"/stripe/webhook", data=b"{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
)
self.assertEqual(
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
)
@mock.patch("apps.dashboard.views.stripe")
def test_unknown_purchase_id_in_metadata_is_noop(self, mock_stripe):
"""If metadata.purchase_id doesn't match any row, log + 200 (don't
crash the webhook listener — Stripe would retry on 5xx)."""
mock_stripe.Webhook.construct_event.return_value = {
"type": "payment_intent.succeeded",
"data": {"object": {
"id": "pi_unknown",
"metadata": {"purchase_id": "999999"},
}},
}
response = self.client.post(
"/stripe/webhook", data=b"{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
)
self.assertEqual(response.status_code, 200)
self.purchase.refresh_from_db()
self.assertEqual(self.purchase.status, Purchase.PENDING)

View File

@@ -10,6 +10,8 @@ urlpatterns = [
path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'), path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'),
path('wallet/setup-intent', views.setup_intent, name='setup_intent'), path('wallet/setup-intent', views.setup_intent, name='setup_intent'),
path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'), path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'),
path('wallet/shop/buy', views.shop_buy, name='shop_buy'),
path('wallet/shop/confirm', views.shop_confirm, name='shop_confirm'),
path('kit-bag/', views.kit_bag, name='kit_bag'), path('kit-bag/', views.kit_bag, name='kit_bag'),
path('sky/', views.sky_view, name='sky'), path('sky/', views.sky_view, name='sky'),
path('sky/preview', views.sky_preview, name='sky_preview'), path('sky/preview', views.sky_preview, name='sky_preview'),

View File

@@ -13,12 +13,12 @@ from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirec
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
from apps.applets.utils import applet_context, apply_applet_toggle from apps.applets.utils import applet_context, apply_applet_toggle
from apps.drama.models import Note from apps.drama.models import Note
from apps.epic.utils import _compute_distinctions from apps.epic.utils import _compute_distinctions
from apps.lyric.models import PaymentMethod, Token, User, Wallet, is_reserved_username from apps.lyric.models import PaymentMethod, Purchase, ShopItem, Token, User, Wallet, is_reserved_username
APPLET_ORDER = ["wallet", "username", "palette"] APPLET_ORDER = ["wallet", "username", "palette"]
@@ -240,6 +240,146 @@ def save_payment_method(request):
return JsonResponse({"last4": pm.card.last4, "brand": pm.card.brand}) return JsonResponse({"last4": pm.card.last4, "brand": pm.card.brand})
# ── Shop: PaymentIntent flow ─────────────────────────────────────────────────
# Three endpoints split fulfillment responsibility:
# /shop/buy creates a Purchase (PENDING) + a Stripe PaymentIntent.
# Returns client_secret so Stripe.js can confirmCardPayment
# (handles 3DS natively).
# /shop/confirm sync follow-up after Stripe.js confirms client-side. Pulls
# PI status from Stripe; if SUCCEEDED, calls Purchase.fulfill()
# immediately (faster UX than waiting for the webhook round-trip).
# /stripe/webhook async fulfillment from Stripe's webhook delivery. Same
# Purchase.fulfill() call — whichever (confirm or webhook)
# lands first wins; the other becomes a no-op via fulfill()'s
# idempotent guard.
#
# Decisions locked 2026-05-21 in [[project-wallet-shop-expansion]]:
# * Webhook is THE authoritative source for fulfillment (resilient to 3DS,
# network drops, browser closes during checkout).
# * Confirm endpoint is a UX-speedup belt-and-suspenders; never required.
# * Webhook idempotency via Purchase.fulfill()'s status==SUCCEEDED guard.
# * No STRIPE_LIVE_MODE setting — env-var swap is all that's needed.
@login_required(login_url="/")
def shop_buy(request):
"""Create a Stripe PaymentIntent + a PENDING Purchase row.
Body: `shop_item_slug` (form-encoded).
Returns: 200 `{client_secret, purchase_id}` on success;
402 if the user has no saved PaymentMethod;
404 if the slug doesn't match an active ShopItem;
409 if the item's max_owned cap is reached for this user.
"""
stripe.api_key = settings.STRIPE_SECRET_KEY
slug = request.POST.get("shop_item_slug", "")
item = ShopItem.objects.filter(slug=slug, active=True).first()
if item is None:
return HttpResponse(status=404)
if not item.is_available_for(request.user):
return HttpResponse(status=409)
pm = request.user.payment_methods.order_by("-pk").first()
if pm is None:
return HttpResponse(status=402)
intent = stripe.PaymentIntent.create(
amount=item.price_cents,
currency="usd",
customer=request.user.stripe_customer_id,
payment_method=pm.stripe_pm_id,
# `automatic_payment_methods` so Stripe.js picks the right confirm
# method (cards, wallets, etc.) without us hard-coding payment-method-
# type plumbing. `allow_redirects=never` keeps the 3DS challenge in
# the same window — Stripe.js handles the modal natively.
automatic_payment_methods={"enabled": True, "allow_redirects": "never"},
metadata={
# Webhook handler looks up the Purchase by this on
# `payment_intent.succeeded`. Belt-and-suspenders w. looking up
# by `stripe_payment_intent_id` (also unique).
"purchase_id": "_pending_", # overwritten after Purchase.save() below
},
)
purchase = Purchase.objects.create(
user=request.user,
shop_item=item,
stripe_payment_intent_id=intent.id,
amount_cents=item.price_cents,
granted_writs=item.granted_writs,
)
# Now we have purchase.pk — backfill the metadata on the PI so the
# webhook handler can resolve back to it.
stripe.PaymentIntent.modify(
intent.id, metadata={"purchase_id": str(purchase.pk)},
)
return JsonResponse({
"client_secret": intent.client_secret,
"purchase_id": purchase.pk,
})
@login_required(login_url="/")
def shop_confirm(request):
"""Sync follow-up after Stripe.js confirms client-side. Polls the PI
once + fulfills if SUCCEEDED. Idempotent w. the webhook handler via
`Purchase.fulfill()`'s status guard.
Body: `purchase_id` (form-encoded).
Returns: 200 always (sync fulfillment is best-effort; webhook is
authoritative). 404 if the purchase doesn't belong to this user.
"""
stripe.api_key = settings.STRIPE_SECRET_KEY
purchase_id = request.POST.get("purchase_id")
purchase = Purchase.objects.filter(
pk=purchase_id, user=request.user,
).first()
if purchase is None:
return HttpResponse(status=404)
if purchase.status != Purchase.SUCCEEDED:
intent = stripe.PaymentIntent.retrieve(purchase.stripe_payment_intent_id)
if intent.status == "succeeded":
purchase.fulfill()
return JsonResponse({"status": purchase.status})
@csrf_exempt
def stripe_webhook(request):
"""Stripe webhook listener. Verifies signature against
`STRIPE_WEBHOOK_SECRET`; on `payment_intent.succeeded` calls
`Purchase.fulfill()` (idempotent w. `/shop/confirm`).
Always returns 2xx (even on unknown event types or already-fulfilled
purchases) — Stripe retries on 5xx, which would just deliver the same
event repeatedly. 4xx is reserved for signature mismatch (a genuine
auth failure that Stripe should NOT retry).
"""
stripe.api_key = settings.STRIPE_SECRET_KEY
payload = request.body
sig_header = request.headers.get("Stripe-Signature", "")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET,
)
except (ValueError, Exception) as e:
# ValueError = invalid payload; SignatureVerificationError = bad sig.
# Either way, refuse — Stripe will alert if it can't deliver.
if isinstance(e, ValueError) or "Signature" in type(e).__name__:
return HttpResponse(status=400)
raise
if event["type"] == "payment_intent.succeeded":
intent = event["data"]["object"]
purchase_id = intent.get("metadata", {}).get("purchase_id")
purchase = None
if purchase_id and purchase_id.isdigit():
purchase = Purchase.objects.filter(pk=int(purchase_id)).first()
# Fall-back lookup by PI ID in case metadata's missing for any reason.
if purchase is None:
purchase = Purchase.objects.filter(
stripe_payment_intent_id=intent.get("id", ""),
).first()
if purchase is not None:
purchase.fulfill()
return HttpResponse(status=200)
# ── My Sky (personal natal chart) ──────────────────────────────────────────── # ── My Sky (personal natal chart) ────────────────────────────────────────────
def _sky_preview_data(request): def _sky_preview_data(request):

View File

@@ -221,6 +221,7 @@ MAILGUN_DOMAIN = "howdy.earthmanrpg.com" # Your Mailgun domain
# Stripe payment settings # Stripe payment settings
STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "") STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "")
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "") STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "")
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
# PySwiss ephemeris microservice # PySwiss ephemeris microservice
PYSWISS_URL = os.environ.get("PYSWISS_URL", "http://127.0.0.1:8001") PYSWISS_URL = os.environ.get("PYSWISS_URL", "http://127.0.0.1:8001")

View File

@@ -17,6 +17,10 @@ urlpatterns = [
path('billboard/', include('apps.billboard.urls')), path('billboard/', include('apps.billboard.urls')),
path('ap/', include('apps.ap.urls')), path('ap/', include('apps.ap.urls')),
path('.well-known/webfinger', ap_views.webfinger, name='webfinger'), path('.well-known/webfinger', ap_views.webfinger, name='webfinger'),
# Stripe webhook lives at a stable root-level URL (no `dashboard/` prefix
# so we can keep the same endpoint pinned in the Stripe dashboard's
# webhook config across any future app-routing refactors).
path('stripe/webhook', dash_views.stripe_webhook, name='stripe_webhook'),
] ]
# Please remove the following urlpattern # Please remove the following urlpattern