Customer accounts, order tracking, and purchase reviews (#8)
Deploy Beta / docker (push) Successful in 37s
Deploy Beta / deploy-beta (push) Successful in 2m21s
Deploy Beta / unit-tests (push) Successful in 39s

## Summary
- Slim the public contact form to email, interest, and message. Name, phone, and address live on the customer profile instead.
- Customers can register, sign in, save shipping details, and view order history. Logged-in checkout creates a Stripe Customer and saves cards on Stripe (`setup_future_usage`); we only store `stripe_customer_id`.
- Shipment tracking: EasyPost tracker lookup + webhook, plus paste-in numbers from Pirate Ship/Shippo. Customers see carrier status on their orders; `dispatch_due` refreshes open shipments.
- Product reviews (1–5) only after a paid/fulfilled purchase of that product.

Fixes #7

## Test plan
- [ ] Contact form submits with only email + message; extra name/phone/address fields are ignored
- [ ] Register, sign in, save profile (name/phone/shipping)
- [ ] Guest checkout still works; after signup, prior orders with that email show in history
- [ ] Logged-in checkout prefills shipping and does not collect card data locally
- [ ] Portal: buy label or paste a Pirate Ship tracking number, confirm status/events; customer order page shows tracking
- [ ] Product page: non-buyers cannot review; buyers can leave one 1–5 star review
- [ ] Non-staff users hitting `/portal/` redirect to `/account/`

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-09-07 04:53:41 -07:00
parent 23a6035ba8
commit dd37a2a268
66 changed files with 2366 additions and 297 deletions
+98 -2
View File
@@ -24,6 +24,7 @@ from shop.services import (
add_to_cart,
adjust_stock,
available_qty,
create_checkout_session,
create_order_from_cart,
looks_like_stl,
mark_paid,
@@ -370,7 +371,7 @@ class ShopInventoryTests(TestCase):
class ShopPortalTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user("merchant", password="test-pass-123")
self.user = User.objects.create_user("merchant", password="test-pass-123", is_staff=True)
self.client = Client()
self.client.login(username="merchant", password="test-pass-123")
@@ -663,7 +664,7 @@ def _sold_order(product, *, qty=1, paid_at=None, status=None, number=None):
class ShopSalesDashboardTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user("merchant", password="test-pass-123")
self.user = User.objects.create_user("merchant", password="test-pass-123", is_staff=True)
self.client = Client()
self.client.login(username="merchant", password="test-pass-123")
self.dragon = _product(name="Dragon", sku="DRAGON", price=Decimal("18.00"))
@@ -741,3 +742,98 @@ class ShopSalesDashboardTests(TestCase):
sales = self.client.get(reverse("shop_portal:sales"))
self.assertContains(sales, 'class="active"')
class ShopAccountAndReviewTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="buyer@example.com",
email="buyer@example.com",
password="s3cure-pass-123",
)
self.product = _product()
self.client = Client()
self.client.login(username="buyer@example.com", password="s3cure-pass-123")
def test_checkout_attaches_user_and_uses_stripe_customer(self):
session = self.client.session
add_to_cart(session, self.product, 1)
session.save()
captured = {}
class FakeCustomer:
id = "cus_abc"
class FakeCheckout:
id = "cs_abc"
url = "https://stripe.test/pay"
class FakeStripe:
class Customer:
@staticmethod
def create(**kwargs):
captured["customer"] = kwargs
return FakeCustomer()
class checkout:
class Session:
@staticmethod
def create(**kwargs):
captured["session"] = kwargs
return FakeCheckout()
with patch("shop.services._stripe", return_value=FakeStripe):
order = create_order_from_cart(
self.client.session,
email="buyer@example.com",
user=self.user,
)
url = create_checkout_session(
order,
success_url="https://example.test/ok",
cancel_url="https://example.test/no",
)
self.assertEqual(url, "https://stripe.test/pay")
self.assertEqual(order.user, self.user)
self.assertEqual(captured["session"]["customer"], "cus_abc")
self.assertNotIn("customer_email", captured["session"])
self.assertEqual(
captured["session"]["payment_intent_data"]["setup_future_usage"],
"on_session",
)
self.user.customer_profile.refresh_from_db()
self.assertEqual(self.user.customer_profile.stripe_customer_id, "cus_abc")
def test_review_requires_purchase(self):
blocked = self.client.post(
reverse("shop:review", kwargs={"slug": self.product.slug}),
{"rating": "5", "title": "Nope", "body": "Did not buy"},
)
self.assertEqual(blocked.status_code, 302)
self.assertEqual(self.product.reviews.count(), 0)
order = _sold_order(self.product)
order.user = self.user
order.email = self.user.email
order.save(update_fields=["user", "email"])
ok = self.client.post(
reverse("shop:review", kwargs={"slug": self.product.slug}),
{"rating": "5", "title": "Great", "body": "Loved it"},
)
self.assertEqual(ok.status_code, 302)
review = self.product.reviews.get()
self.assertEqual(review.rating, 5)
self.assertEqual(review.user, self.user)
detail = self.client.get(self.product.get_absolute_url())
self.assertContains(detail, "Great")
self.assertContains(detail, "You already reviewed")
def test_order_history_hides_other_users(self):
mine = _sold_order(self.product, number="ORD-MINE")
mine.user = self.user
mine.save(update_fields=["user"])
_sold_order(self.product, number="ORD-THEIRS")
page = self.client.get(reverse("account:orders"))
self.assertContains(page, "ORD-MINE")
self.assertNotContains(page, "ORD-THEIRS")