Grades 4 and 5, Bazaar purchase from the app, pack SKUs
- rag: MATH_4 / MATH_5 textbooks (TOC and page offsets read from the PDFs) - content: grades 4 and 5 in the seed files: 7 units / 28 nodes each, 204 + 194 questions (every node >= 4), all re-solved blind once - subscription: Plan.bazaar_sku (admin); POST /api/subscription/purchase accepts paymentMethod=bazaar for packs, rejects unknown methods (was ignored) - payment: idempotency key cannot switch provider; discount codes refused on Bazaar orders (Bazaar charges the fixed SKU price) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,8 @@ from .models import Plan, Subscription
|
||||
|
||||
@admin.register(Plan)
|
||||
class PlanAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'title', 'price', 'currency', 'duration_days', 'ai_daily_quota', 'discount_percent', 'popular')
|
||||
list_display = ('id', 'title', 'price', 'currency', 'duration_days', 'ai_daily_quota', 'discount_percent', 'popular',
|
||||
'bazaar_sku')
|
||||
list_editable = ('ai_daily_quota',)
|
||||
|
||||
|
||||
|
||||
22
subscription/migrations/0012_plan_bazaar_sku.py
Normal file
22
subscription/migrations/0012_plan_bazaar_sku.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-03 12:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('subscription', '0011_questioncredit_refund_reason'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='bazaar_sku',
|
||||
field=models.CharField(blank=True, default='', help_text='SKUِ محصولِ درون\u200cبرنامه\u200cای در پیشخانِ کافه\u200cبازار؛ خالی = در بازار فروخته نمی\u200cشود.', max_length=100),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='plan',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('bazaar_sku', ''), _negated=True), fields=('bazaar_sku',), name='plan_bazaar_sku_unique_when_set'),
|
||||
),
|
||||
]
|
||||
@@ -43,6 +43,18 @@ class Plan(models.Model):
|
||||
default=0, help_text='فقط بسته: چند روز قلبِ نامحدود هدیه.')
|
||||
# پلنی که دیگر فروخته نمیشود ولی مشترکهای فعلیاش هنوز به آن وصلاند.
|
||||
on_sale = models.BooleanField(default=True, help_text='در صفحهی خرید نشان داده شود.')
|
||||
# شناسهی همین بسته در پیشخانِ کافهبازار (محصولِ درونبرنامهای، مصرفشدنی). خریدِ بازار
|
||||
# SKU را فقط از اینجا میگیرد، هرگز از کلاینت: وگرنه توکنِ ارزانترین محصول بستهی گران را
|
||||
# فعال میکرد. خالی یعنی این بسته در بازار فروخته نمیشود (درخواستش ۴۰۰ میگیرد).
|
||||
bazaar_sku = models.CharField(
|
||||
max_length=100, blank=True, default='',
|
||||
help_text='SKUِ محصولِ درونبرنامهای در پیشخانِ کافهبازار؛ خالی = در بازار فروخته نمیشود.')
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['bazaar_sku'], condition=~models.Q(bazaar_sku=''),
|
||||
name='plan_bazaar_sku_unique_when_set'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@@ -15,6 +15,8 @@ class PlanSerializer(serializers.Serializer):
|
||||
kind = serializers.ChoiceField(choices=['pack', 'subscription'])
|
||||
questionCredits = serializers.IntegerField(help_text='بسته: سوالهایی که به اعتبار اضافه میشود (منقضی نمیشود).')
|
||||
heartsDays = serializers.IntegerField(help_text='بسته: روزهای قلبِ نامحدودِ هدیه.')
|
||||
bazaarSku = serializers.CharField(
|
||||
allow_blank=True, help_text='SKUِ همین بسته در کافهبازار؛ خالی یعنی در بازار خریدنی نیست.')
|
||||
|
||||
|
||||
class PlansListResponseSerializer(serializers.Serializer):
|
||||
@@ -27,12 +29,17 @@ class PurchaseRequestSerializer(serializers.Serializer):
|
||||
# هدر Idempotency-Key هم روی همین درخواست لازمه (بخش ۴.۳ سند API)؛
|
||||
# از request.headers خونده میشه، نه از بدنه.
|
||||
planId = serializers.CharField()
|
||||
paymentMethod = serializers.CharField(required=False, default='zarinpal')
|
||||
# تا ۲۰۲۶-۱۰-۰۳ هر مقداری پذیرفته و نادیده گرفته میشد، پس «bazaar» بیصدا به زرینپال میرفت.
|
||||
paymentMethod = serializers.ChoiceField(choices=['zarinpal', 'bazaar'], required=False, default='zarinpal')
|
||||
|
||||
|
||||
class PurchaseResponseSerializer(serializers.Serializer):
|
||||
orderId = serializers.CharField()
|
||||
paymentUrl = serializers.CharField()
|
||||
paymentUrl = serializers.CharField(
|
||||
allow_null=True, help_text='زرینپال: آدرسی که کاربر به آن برود. بازار: null.')
|
||||
bazaarSku = serializers.CharField(
|
||||
required=False, help_text='فقط بازار: SKUی که اپِ اندروید با Poolakey میخرد؛ '
|
||||
'developerPayload همان orderId باشد.')
|
||||
|
||||
|
||||
class SubscriptionStatusResponseSerializer(serializers.Serializer):
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.db.models import F
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
@@ -69,6 +70,7 @@ class PlansListView(APIView):
|
||||
'kind': p.kind,
|
||||
'questionCredits': p.question_credits,
|
||||
'heartsDays': p.hearts_days,
|
||||
'bazaarSku': p.bazaar_sku,
|
||||
}
|
||||
for p in Plan.objects.filter(on_sale=True).order_by('kind', 'price')
|
||||
],
|
||||
@@ -119,6 +121,21 @@ class PurchaseView(APIView):
|
||||
plan = get_object_or_404(Plan, pk=v['planId'], on_sale=True)
|
||||
idempotency_key = request.headers.get('Idempotency-Key')
|
||||
|
||||
if v['paymentMethod'] == 'bazaar':
|
||||
# فقط بسته: اشتراکها دیگر فروخته نمیشوند و مسیرِ اشتراکِ بازار (تمدیدِ خودکار) هرگز
|
||||
# با خریدِ واقعی آزموده نشده. SKU از پلن، نه از کلاینت.
|
||||
if plan.kind != 'pack':
|
||||
raise ValidationError({'paymentMethod': 'خرید از بازار فقط برای بستههای سوال است.'})
|
||||
if not plan.bazaar_sku:
|
||||
raise ValidationError({'planId': 'این بسته هنوز در کافهبازار تعریف نشده است.'})
|
||||
order, _ = create_order(
|
||||
user_id=user.id, amount=plan.price, currency=plan.currency,
|
||||
idempotency_key=idempotency_key, plan_id=plan.id, plan_title=plan.title,
|
||||
provider='bazaar', bazaar_product_sku=plan.bazaar_sku,
|
||||
)
|
||||
return Response(PurchaseResponseSerializer(
|
||||
{'orderId': str(order.id), 'paymentUrl': None, 'bazaarSku': order.bazaar_product_sku}).data)
|
||||
|
||||
if plan.kind == 'pack':
|
||||
# بسته به Subscription دست نمیزند: اعتبار و روزهای قلب بعد از پرداخت از روی
|
||||
# خودِ سفارش داده میشوند (payment.services → subscription.services.grant_pack).
|
||||
|
||||
Reference in New Issue
Block a user