diff --git a/shop/admin.py b/shop/admin.py index d1b0720..3344be5 100644 --- a/shop/admin.py +++ b/shop/admin.py @@ -6,7 +6,12 @@ from shop.models import Presenter, Presentation, Participation, Coupon, Payment, PresentationTag admin.site.register(Presenter) -admin.site.register(PresentationTag) + + +@admin.register(PresentationTag) +class PresentationTagAdmin(admin.ModelAdmin): + list_display = ('en_name', 'fa_name', 'color') + search_fields = ('en_name', 'fa_name') @admin.register(Payment) class PaymentAdmin(admin.ModelAdmin): @@ -15,12 +20,14 @@ class PaymentAdmin(admin.ModelAdmin): @admin.register(Participation) class ParticipationAdmin(admin.ModelAdmin): search_fields = ['user__phone_number'] - list_display = ['__str__','payment_state', 'presentation__cost'] + list_display = ['__str__', 'payment_state', 'is_capacity_exempt', 'presentation__cost'] + list_filter = ['payment_state', 'is_capacity_exempt'] @admin.register(Coupon) class CouponAdmin(admin.ModelAdmin): - list_display = ('name', 'used') + list_display = ('name', 'percentage', 'count', 'preserve_capacity', 'used') + filter_horizontal = ('eligible_presentations',) def used(self, obj): return Payment.objects.filter(payment_state="COMPLETED", coupon=obj).count() diff --git a/shop/migrations/0019_bilingual_presentation_tags.py b/shop/migrations/0019_bilingual_presentation_tags.py new file mode 100644 index 0000000..ffbe904 --- /dev/null +++ b/shop/migrations/0019_bilingual_presentation_tags.py @@ -0,0 +1,36 @@ +from django.db import migrations, models + + +def populate_persian_names(apps, schema_editor): + PresentationTag = apps.get_model("shop", "PresentationTag") + translations = { + "Online": "آنلاین", + "In-Person": "حضوری", + "Beginner": "مبتدی", + "Intermediate": "متوسط", + "Advanced": "پیشرفته", + } + for tag in PresentationTag.objects.all(): + tag.fa_name = translations.get(tag.en_name, tag.en_name) + tag.save(update_fields=["fa_name"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("shop", "0018_launch_safety_constraints"), + ] + + operations = [ + migrations.RenameField( + model_name="presentationtag", + old_name="name", + new_name="en_name", + ), + migrations.AddField( + model_name="presentationtag", + name="fa_name", + field=models.CharField(default="", max_length=63), + preserve_default=False, + ), + migrations.RunPython(populate_persian_names, migrations.RunPython.noop), + ] diff --git a/shop/migrations/0020_coupon_eligible_presentations_and_more.py b/shop/migrations/0020_coupon_eligible_presentations_and_more.py new file mode 100644 index 0000000..ca2e65c --- /dev/null +++ b/shop/migrations/0020_coupon_eligible_presentations_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.1.5 on 2026-10-07 05:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0019_bilingual_presentation_tags'), + ] + + operations = [ + migrations.AddField( + model_name='coupon', + name='eligible_presentations', + field=models.ManyToManyField(blank=True, help_text='Leave empty to apply this coupon to the whole cart.', related_name='coupons', to='shop.presentation'), + ), + migrations.AddField( + model_name='coupon', + name='preserve_capacity', + field=models.BooleanField(default=False, help_text="When enabled, registrations discounted by this coupon do not reduce the selected presentations' remaining capacity."), + ), + migrations.AddField( + model_name='participation', + name='is_capacity_exempt', + field=models.BooleanField(default=False, help_text='Completed registrations with this flag do not reduce remaining capacity.'), + ), + migrations.AddField( + model_name='payment', + name='coupon_participations', + field=models.ManyToManyField(blank=True, help_text="Snapshot of participations to which this payment's coupon applied.", related_name='coupon_payments', to='shop.participation'), + ), + migrations.AddField( + model_name='payment', + name='coupon_preserves_capacity', + field=models.BooleanField(default=False, help_text="Snapshot of the coupon's capacity behavior when checkout started."), + ), + ] diff --git a/shop/models.py b/shop/models.py index b92a8a7..d44cee3 100644 --- a/shop/models.py +++ b/shop/models.py @@ -36,11 +36,12 @@ def __str__(self): return f'{self.last_name} {self.first_name}' class PresentationTag(models.Model): - name = models.CharField(max_length=63) + en_name = models.CharField(max_length=63) + fa_name = models.CharField(max_length=63) color = ColorField(default="#FA175C") def __str__(self): - return self.name + return f"{self.fa_name} / {self.en_name}" class Presentation(models.Model): @@ -76,6 +77,7 @@ def get_remained_capacity(self): # seat at the same time. committed = Participation.objects.filter( presentation=self, + is_capacity_exempt=False, ).filter( models.Q(payment_state="COMPLETED") | models.Q( @@ -98,6 +100,10 @@ class Participation(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='participations') presentation = models.ForeignKey(Presentation, on_delete=models.CASCADE, related_name='participations') payment_state = models.CharField(choices=PAYMENT_STATES, default="PENDING", max_length=10) + is_capacity_exempt = models.BooleanField( + default=False, + help_text="Completed registrations with this flag do not reduce remaining capacity.", + ) class Meta: constraints = [ @@ -115,6 +121,19 @@ class Coupon(models.Model): name = models.CharField(max_length=50, primary_key=True, help_text="Don't use / in the name.") count = models.PositiveIntegerField() percentage = models.IntegerField(default=0.0, help_text='Enter a number between 0 to 100.') + eligible_presentations = models.ManyToManyField( + Presentation, + blank=True, + related_name="coupons", + help_text="Leave empty to apply this coupon to the whole cart.", + ) + preserve_capacity = models.BooleanField( + default=False, + help_text=( + "When enabled, registrations discounted by this coupon do not reduce " + "the selected presentations' remaining capacity." + ), + ) def __str__(self): return self.name @@ -143,6 +162,16 @@ class Payment(models.Model): created_date = models.DateTimeField(auto_now_add=True) verified_date = models.DateTimeField(null=True, blank=True) coupon = models.ForeignKey(Coupon, on_delete=models.SET_NULL, default=None, null=True, blank=True) + coupon_participations = models.ManyToManyField( + Participation, + blank=True, + related_name="coupon_payments", + help_text="Snapshot of participations to which this payment's coupon applied.", + ) + coupon_preserves_capacity = models.BooleanField( + default=False, + help_text="Snapshot of the coupon's capacity behavior when checkout started.", + ) accessories = models.ManyToManyField(Accessory, "payment_accessories") is_competition_payment = models.BooleanField(default=False) diff --git a/shop/serializers.py b/shop/serializers.py index 131e38a..f59a5e6 100644 --- a/shop/serializers.py +++ b/shop/serializers.py @@ -8,9 +8,12 @@ class Meta: fields = ['first_name', 'last_name', 'email', 'description', 'avatar', 'linkedin'] class PresentationTagSerializer(serializers.ModelSerializer): + # Keep the legacy field until all deployed frontend clients use en_name. + name = serializers.CharField(source='en_name', read_only=True) + class Meta: model = PresentationTag - fields = ['name', 'color'] + fields = ['name', 'en_name', 'fa_name', 'color'] class PresentationSerializer(serializers.ModelSerializer): remained_capacity = serializers.SerializerMethodField() @@ -51,7 +54,9 @@ class Meta: class CouponSerializer(serializers.ModelSerializer): is_valid = serializers.SerializerMethodField() class Meta: - fields = ['percentage', 'is_valid'] + fields = [ + 'percentage', 'is_valid', 'eligible_presentations', 'preserve_capacity', + ] model = Coupon def get_is_valid(self, obj): diff --git a/shop/test_payment_flow.py b/shop/test_payment_flow.py index a9a3196..113d118 100644 --- a/shop/test_payment_flow.py +++ b/shop/test_payment_flow.py @@ -11,8 +11,8 @@ from rest_framework.test import APITestCase from accounts.models import Accessory, User -from shop.models import Coupon, Payment, Presentation, Participation -from shop.serializers import CouponSerializer +from shop.models import Coupon, Payment, Presentation, PresentationTag, Participation +from shop.serializers import CouponSerializer, PresentationTagSerializer from shop.payments import ZarrinPal @@ -147,6 +147,42 @@ def test_provider_callback_verifies_and_settles_before_frontend_redirect(self, v self.assertEqual(payment.ref_id, "REF123") self.assertEqual(self.participation.payment_state, "COMPLETED") + verify_response = self.client.post( + reverse("payment-verify"), + {"authority": payment.authority}, + format="json", + ) + self.assertEqual(verify_response.status_code, 200) + self.assertEqual(verify_response.data["status"], "success") + self.assertEqual(verify_response.data["ref_id"], "REF123") + + @patch("shop.views.ZarrinPal.verify_payment") + def test_callback_preserves_gateway_ok_for_retryable_verification(self, verify_payment): + payment = Payment.objects.create( + user=self.user, + total_price=Decimal("10000.00"), + authority="RETRYABLE123", + ) + payment.participations.add(self.participation) + verify_payment.return_value = { + "status": "unexpected", + "ref_id": None, + "card_pan": None, + "error": "Temporary provider timeout", + } + + self.client.force_authenticate(user=None) + response = self.client.get( + reverse("payment-provider-callback"), + {"Authority": payment.authority, "Status": "OK"}, + ) + + self.assertEqual(response.status_code, 302) + target = urlparse(response["Location"]) + self.assertEqual(parse_qs(target.query)["Status"], ["OK"]) + payment.refresh_from_db() + self.assertEqual(payment.payment_state, "PENDING") + def test_remove_accepts_presentation_id(self): self.participation.delete() participation = Participation.objects.create( @@ -222,8 +258,205 @@ def test_started_payment_reserves_capacity(self): self.assertEqual(self.presentation.get_remained_capacity(), 0) + @patch("shop.views.ZarrinPal.create_payment") + def test_scoped_coupon_discounts_only_selected_presentations(self, create_payment): + self.presentation.cost = Decimal("10000.00") + self.presentation.save(update_fields=["cost"]) + start = timezone.now() + timedelta(days=30) + other_presentation = Presentation.objects.create( + service_type="WORKSHOP", + en_title="Unrelated workshop", + fa_title="کارگاه نامرتبط", + start=start, + end=start + timedelta(hours=1), + en_description="Test", + fa_description="Test", + capacity=10, + cost=Decimal("20000.00"), + ) + other_participation = Participation.objects.create( + user=self.user, + presentation=other_presentation, + ) + accessory = Accessory.objects.create( + name="Unrelated accessory", + description="Must not receive the scoped discount", + price=Decimal("3000.00"), + img="unrelated-accessory.png", + is_active=True, + ) + coupon = Coupon.objects.create(name="TARGET50", count=5, percentage=50) + coupon.eligible_presentations.add(self.presentation) + create_payment.return_value = { + "status": "success", + "authority": "SCOPED123", + "link": "https://ceit-ssc.ir/payment/start?gateway=scoped", + } + + response = self.client.post( + reverse("payment-pay-all"), + {"coupon": coupon.pk, "accessories": [accessory.pk]}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + create_payment.assert_called_once_with( + amount=Decimal("28000.00"), + mobile=self.user.phone_number, + email=self.user.email, + ) + payment = Payment.objects.get(authority="SCOPED123") + self.assertEqual(payment.total_price, Decimal("28000.00")) + self.assertSetEqual( + set(payment.coupon_participations.all()), + {self.participation}, + ) + self.assertNotIn(other_participation, payment.coupon_participations.all()) + + def test_scoped_coupon_is_rejected_without_an_eligible_cart_item(self): + start = timezone.now() + timedelta(days=30) + other_presentation = Presentation.objects.create( + service_type="WORKSHOP", + en_title="Coupon-only workshop", + fa_title="کارگاه مخصوص کد", + start=start, + end=start + timedelta(hours=1), + en_description="Test", + fa_description="Test", + capacity=10, + cost=Decimal("10000.00"), + ) + coupon = Coupon.objects.create(name="OTHERONLY", count=5, percentage=50) + coupon.eligible_presentations.add(other_presentation) + + response = self.client.post( + reverse("payment-pay-all"), + {"coupon": coupon.pk}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("does not apply", response.data["detail"]) + self.assertFalse(Payment.objects.exists()) + + @patch("shop.views.ZarrinPal.create_payment") + @patch("shop.views.ZarrinPal.verify_payment") + def test_capacity_preserving_coupon_does_not_consume_a_seat( + self, + verify_payment, + create_payment, + ): + self.presentation.capacity = 1 + self.presentation.cost = Decimal("10000.00") + self.presentation.save(update_fields=["capacity", "cost"]) + coupon = Coupon.objects.create( + name="GUEST50", + count=1, + percentage=50, + preserve_capacity=True, + ) + coupon.eligible_presentations.add(self.presentation) + create_payment.return_value = { + "status": "success", + "authority": "GUEST123", + "link": "https://ceit-ssc.ir/payment/start?gateway=guest", + } + verify_payment.return_value = { + "status": "success", + "ref_id": "GUEST-REF", + "card_pan": "621986******1234", + "error": None, + } + + checkout_response = self.client.post( + reverse("payment-pay-all"), + {"coupon": coupon.pk}, + format="json", + ) + payment = Payment.objects.get(authority="GUEST123") + + self.assertEqual(checkout_response.status_code, 200) + self.assertTrue(payment.coupon_preserves_capacity) + self.assertEqual(self.presentation.get_remained_capacity(), 0) + + verify_response = self.client.post( + reverse("payment-verify"), + {"authority": payment.authority}, + format="json", + ) + + self.assertEqual(verify_response.status_code, 200) + self.participation.refresh_from_db() + coupon.refresh_from_db() + self.assertEqual(self.participation.payment_state, "COMPLETED") + self.assertTrue(self.participation.is_capacity_exempt) + self.assertEqual(self.presentation.get_remained_capacity(), 1) + self.assertEqual(coupon.count, 0) + + def test_free_capacity_preserving_coupon_is_settled_immediately(self): + original_remaining = self.presentation.get_remained_capacity() + start = timezone.now() + timedelta(days=30) + other_presentation = Presentation.objects.create( + service_type="WORKSHOP", + en_title="Regular free workshop", + fa_title="کارگاه رایگان عادی", + start=start, + end=start + timedelta(hours=1), + en_description="Test", + fa_description="Test", + capacity=2, + cost=Decimal("0"), + ) + other_participation = Participation.objects.create( + user=self.user, + presentation=other_presentation, + ) + coupon = Coupon.objects.create( + name="FREEGUEST", + count=1, + percentage=100, + preserve_capacity=True, + ) + coupon.eligible_presentations.add(self.presentation) + + response = self.client.post( + reverse("payment-pay-all"), + {"coupon": coupon.pk}, + format="json", + ) + + self.assertEqual(response.status_code, 204) + self.participation.refresh_from_db() + coupon.refresh_from_db() + self.assertEqual(self.participation.payment_state, "COMPLETED") + self.assertTrue(self.participation.is_capacity_exempt) + self.assertEqual(self.presentation.get_remained_capacity(), original_remaining) + other_participation.refresh_from_db() + self.assertEqual(other_participation.payment_state, "COMPLETED") + self.assertFalse(other_participation.is_capacity_exempt) + self.assertEqual(other_presentation.get_remained_capacity(), 1) + self.assertEqual(coupon.count, 0) + class ShopValidationTests(TestCase): + def test_presentation_tag_exposes_both_languages_and_legacy_name(self): + tag = PresentationTag.objects.create( + en_name="Beginner", + fa_name="مبتدی", + color="#9B85FA", + ) + + data = PresentationTagSerializer(tag).data + + self.assertEqual(data["name"], "Beginner") + self.assertEqual(data["en_name"], "Beginner") + self.assertEqual(data["fa_name"], "مبتدی") + + def test_presentation_tag_requires_both_languages(self): + tag = PresentationTag(en_name="Beginner", fa_name="") + with self.assertRaises(ValidationError): + tag.full_clean() + def test_negative_capacity_is_rejected(self): start = timezone.now() + timedelta(days=1) presentation = Presentation( diff --git a/shop/views.py b/shop/views.py index 4603c13..6679b20 100644 --- a/shop/views.py +++ b/shop/views.py @@ -188,6 +188,42 @@ def pay_all(self, request): for item in Presentation.objects.select_for_update().filter(pk__in=presentation_ids) } + coupon = None + coupon_participations = [] + coupon_presentation_ids = set() + if coupon_code: + coupon = ( + Coupon.objects.select_for_update() + .filter(name=coupon_code, count__gt=0) + .first() + ) + if not coupon: + return Response( + {"detail": "کد تخفیف نامعتبر!"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + coupon_presentation_ids = set( + coupon.eligible_presentations.values_list("pk", flat=True) + ) + coupon_participations = [ + participation + for participation in participations + if ( + not coupon_presentation_ids + or participation.presentation_id in coupon_presentation_ids + ) + ] + if coupon_presentation_ids and not coupon_participations: + return Response( + {"detail": "This coupon does not apply to any presentation in the cart."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + capacity_exempt_participation_ids = { + participation.pk for participation in coupon_participations + } if coupon and coupon.preserve_capacity else set() + for participation in participations: presentation = locked_presentations[participation.presentation_id] @@ -201,7 +237,10 @@ def pay_all(self, request): return Response({'detail': 'Registration is closed for this presentation.'}, status=status.HTTP_400_BAD_REQUEST) - if presentation.get_remained_capacity() < 1: + if ( + participation.pk not in capacity_exempt_participation_ids + and presentation.get_remained_capacity() < 1 + ): return Response( {'detail': f'No remaining capacity for presentation {presentation.en_title}.'}, status=status.HTTP_400_BAD_REQUEST @@ -218,19 +257,31 @@ def pay_all(self, request): ) presentation_total = sum( - (participation.presentation.cost for participation in participations), + ( + locked_presentations[participation.presentation_id].cost + for participation in participations + ), Decimal("0"), ) accessory_total = sum((accessory.price for accessory in accessories), Decimal("0")) total_price = presentation_total + accessory_total - coupon = None - if coupon_code: - coupon = Coupon.objects.select_for_update().filter(name=coupon_code, count__gt=0).first() - if not coupon: - return Response({"detail": "کد تخفیف نامعتبر!"}, - status=status.HTTP_400_BAD_REQUEST) - discount = (Decimal(coupon.percentage) / Decimal("100")) * total_price + if coupon: + if coupon_presentation_ids: + discountable_total = sum( + ( + locked_presentations[participation.presentation_id].cost + for participation in coupon_participations + ), + Decimal("0"), + ) + else: + # An empty scope preserves the behavior of existing coupons: + # discount every presentation and accessory in the checkout. + discountable_total = total_price + discount = ( + Decimal(coupon.percentage) / Decimal("100") + ) * discountable_total total_price -= discount total_price = max(total_price, Decimal("0")).quantize( @@ -241,6 +292,10 @@ def pay_all(self, request): user.accessories.add(*accessories) participation_queryset.update(payment_state="COMPLETED") if coupon: + if coupon.preserve_capacity: + Participation.objects.filter( + pk__in=capacity_exempt_participation_ids, + ).update(is_capacity_exempt=True) coupon.count -= 1 coupon.save(update_fields=["count"]) return Response(None, status=status.HTTP_204_NO_CONTENT) @@ -249,9 +304,11 @@ def pay_all(self, request): user=user, total_price=total_price, coupon=coupon, + coupon_preserves_capacity=bool(coupon and coupon.preserve_capacity), ) payment.participations.set(participations) payment.accessories.set(accessories) + payment.coupon_participations.set(coupon_participations) zarrinpal = ZarrinPal() zarrinpal_response = zarrinpal.create_payment( @@ -287,6 +344,7 @@ def _verify_authority(authority): if payment.payment_state == "COMPLETED": return payment, { + "status": "success", "detail": "Payment has already been verified.", "ref_id": payment.ref_id, "card_pan": payment.card_pan, @@ -308,6 +366,7 @@ def _verify_authority(authority): else status.HTTP_502_BAD_GATEWAY ) return payment, { + "status": zarrinpal_response["status"], "detail": "Payment verification failed.", "error": zarrinpal_response.get("error"), }, response_status @@ -331,6 +390,8 @@ def _verify_authority(authority): payment.user.save(update_fields=["is_signed_up_for_competition"]) else: payment.participations.update(payment_state="COMPLETED") + if payment.coupon_preserves_capacity: + payment.coupon_participations.update(is_capacity_exempt=True) if payment.coupon_id: coupon = Coupon.objects.select_for_update().get(pk=payment.coupon_id) if coupon.count > 0: @@ -339,6 +400,7 @@ def _verify_authority(authority): payment.user.accessories.add(*payment.accessories.all()) return payment, { + "status": "success", "detail": "Payment verified successfully.", "ref_id": payment.ref_id, "card_pan": payment.card_pan, @@ -359,14 +421,25 @@ def verify(self, request): @action(methods=['get'], detail=False, permission_classes=[], url_path='provider-callback') def provider_callback(self, request): authority = request.query_params.get("Authority") + gateway_status = request.query_params.get("Status") payment = None response_status = status.HTTP_400_BAD_REQUEST if authority: payment, _payload, response_status = self._verify_authority(authority) + # If the gateway reported success but our verification request had a + # temporary transport/provider failure, preserve OK so the frontend + # performs its own verification retry. It only displays success after + # that API call succeeds. Explicit provider rejection remains NOK. + retryable_verification = ( + gateway_status == "OK" + and response_status == status.HTTP_502_BAD_GATEWAY + ) query = { "Authority": authority or "", - "Status": "OK" if response_status == status.HTTP_200_OK else "NOK", + "Status": "OK" if ( + response_status == status.HTTP_200_OK or retryable_verification + ) else "NOK", } if payment and payment.ref_id: query["RefID"] = payment.ref_id