Skip to content

Commit fac82c1

Browse files
feat: Fix target id
1 parent c573ff1 commit fac82c1

4 files changed

Lines changed: 69 additions & 59 deletions

File tree

mono/payment/domain_hooks.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
def on_payment_success(payment: Payment):
55
if payment.target_type == Payment.TargetType.COMPETITION:
6+
# TODO: Fix this
67
from competitions.models import TeamRequest
78
from competitions.services import mark_payment_final
89
tr = TeamRequest.objects.get(id=payment.target_id)
@@ -15,6 +16,7 @@ def on_payment_success(payment: Payment):
1516
set_status_final(list(regs_qs))
1617

1718
def on_payment_failure(payment: Payment):
19+
# TODO: Fix this
1820
if payment.target_type == Payment.TargetType.COMPETITION:
1921
from competitions.models import TeamRequest
2022
from competitions.services import mark_payment_rejected

mono/payment/models.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ class TargetType(models.TextChoices):
2121

2222
status = models.CharField(max_length=24, choices=Status.choices, default=Status.PENDING)
2323

24-
# Zarinpal field
2524
authority = models.CharField(max_length=64, blank=True, db_index=True)
2625
ref_id = models.CharField(max_length=64, blank=True)
2726
card_pan = models.CharField(max_length=32, blank=True)

mono/payment/services.py

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
# payment/services.py
22

3+
from __future__ import annotations
4+
35
import requests
46
from dataclasses import dataclass
5-
from typing import Optional, List, Dict
7+
from typing import Optional, List, Dict, Any
68
from django.conf import settings
79
from django.db import transaction
8-
910
from django.contrib.auth import get_user_model
1011

1112
from acm.exceptions import CustomAPIException
@@ -38,7 +39,7 @@ def _callback_url() -> str:
3839

3940
def _unverified_list() -> List[Dict]:
4041
"""
41-
Fetches the list of unverified authorities from Zarinpal.
42+
Fetch the list of unverified authorities from Zarinpal.
4243
Returns [] on non-100 or network error.
4344
"""
4445
url = f"{Z_BASE}/unVerified.json"
@@ -65,8 +66,6 @@ def _request_payment(
6566
"metadata": {"email": email, **({"mobile": mobile} if mobile else {})},
6667
}
6768
r = requests.post(url, json=payload, headers=HEADERS, timeout=20)
68-
# optional debug:
69-
# print(f"status code: {r.status_code}, message: {r.text}")
7069
r.raise_for_status()
7170
return r.json()
7271

@@ -85,15 +84,18 @@ def initiate_payment_for_target(
8584
*,
8685
user: User,
8786
target_type: str,
88-
target_id: int,
87+
target_id: str, # e.g. "PARENT_ID,CHILD_ID_1,CHILD_ID_2"
8988
amount: int,
9089
description: str = "",
90+
extra_metadata: Optional[Dict[str, Any]] = None, # attach {"reg_id": ..., "parent_course_id": ..., "child_course_ids":[...], ...}
9191
) -> StartPayResult:
9292
"""
9393
1) Check existing PENDING payments for the user; if any authority is still unverified at the gateway,
9494
attempt to verify it. If verification succeeds for SAME target, raise conflict.
9595
2) Create a new Zarinpal request and persist a PENDING Payment with the returned authority.
9696
3) Return StartPay URL.
97+
98+
NOTE: target_id is a string so we can store composite IDs (e.g., "42,101,102").
9799
"""
98100
if not user or not user.is_authenticated:
99101
raise CustomAPIException(
@@ -128,7 +130,7 @@ def initiate_payment_for_target(
128130
p.card_hash = str(d.get("card_hash", "") or "")
129131
p.save()
130132
# conflict if same purchase already paid
131-
if p.target_type == target_type and p.target_id == target_id:
133+
if p.target_type == target_type and str(p.target_id) == str(target_id):
132134
raise CustomAPIException(
133135
code=EC.PAY_EXISTING_SUCCESS,
134136
message="Existing successful payment found for this purchase",
@@ -151,12 +153,12 @@ def initiate_payment_for_target(
151153
Payment.objects.create(
152154
user=user,
153155
target_type=target_type,
154-
target_id=target_id,
156+
target_id=str(target_id),
155157
amount=amount,
156158
status=Payment.Status.PG_INITIATE_ERROR,
157159
zarinpal_message=str(e),
158160
description=description or "",
159-
metadata={"stage": "request", "exc": str(e)},
161+
metadata={"stage": "request", "exc": str(e), **(extra_metadata or {})},
160162
)
161163
raise CustomAPIException(
162164
code=EC.PAY_INIT_FAILED,
@@ -170,13 +172,13 @@ def initiate_payment_for_target(
170172
Payment.objects.create(
171173
user=user,
172174
target_type=target_type,
173-
target_id=target_id,
175+
target_id=str(target_id),
174176
amount=amount,
175177
status=Payment.Status.PG_INITIATE_ERROR,
176178
zarinpal_code=str(code),
177179
zarinpal_message=d.get("message", "") or "",
178180
description=description or "",
179-
metadata={"stage": "request", "resp": d},
181+
metadata={"stage": "request", "resp": d, **(extra_metadata or {})},
180182
)
181183
raise CustomAPIException(
182184
code=EC.PAY_GATEWAY_REFUSED,
@@ -188,12 +190,12 @@ def initiate_payment_for_target(
188190
pay = Payment.objects.create(
189191
user=user,
190192
target_type=target_type,
191-
target_id=target_id,
193+
target_id=str(target_id), # store as string
192194
amount=amount,
193195
status=Payment.Status.PENDING,
194196
authority=authority,
195197
description=description or "",
196-
metadata={"fee_type": d.get("fee_type"), "fee": d.get("fee")},
198+
metadata={"fee_type": d.get("fee_type"), "fee": d.get("fee"), **(extra_metadata or {})},
197199
)
198200
startpay_url = f"https://payment.zarinpal.com/pg/StartPay/{authority}"
199201
return StartPayResult(url=startpay_url, payment=pay)
@@ -204,6 +206,8 @@ def verify_by_authority(*, user: User, authority: str) -> Payment:
204206
"""
205207
Verify a payment by authority for the given user (frontend passes authority after redirect).
206208
On success/failure, updates Payment and triggers domain hooks.
209+
210+
If metadata includes 'reg_id', finalize that registration (parent+children) immediately.
207211
"""
208212
if not user or not user.is_authenticated:
209213
raise CustomAPIException(
@@ -222,10 +226,11 @@ def verify_by_authority(*, user: User, authority: str) -> Payment:
222226
)
223227

224228
if p.status != Payment.Status.PENDING:
225-
# Already processed; return as-is (frontend can branch on status)
229+
# Already processed; return as-is
226230
return p
227231

228232
merchant = settings.ZARINPAL_MERCHANT_ID
233+
229234
try:
230235
res = _verify_payment(merchant_id=merchant, amount=p.amount, authority=authority)
231236
except requests.RequestException as e:
@@ -251,5 +256,22 @@ def verify_by_authority(*, user: User, authority: str) -> Payment:
251256
p.status = Payment.Status.FAILED
252257
p.save()
253258
on_payment_failure(p)
259+
return p
260+
261+
# If we know the registration that initiated this payment, finalize it now.
262+
try:
263+
reg_id = (p.metadata or {}).get("reg_id")
264+
if reg_id:
265+
from presentations.services import set_status_final # local import to avoid circulars
266+
from presentations.models import Registration
267+
reg = (
268+
Registration.objects
269+
.select_for_update()
270+
.select_related("course", "user")
271+
.get(id=int(reg_id), user=user)
272+
)
273+
set_status_final([reg])
274+
except Exception:
275+
pass
254276

255277
return p

mono/presentations/services.py

Lines changed: 31 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,6 @@
1616

1717

1818
def _is_full(capacity: int | None, taken: int) -> bool:
19-
"""
20-
Capacity rules:
21-
- None => unlimited (never full)
22-
- 0 => closed (always full)
23-
- >0 => full when taken >= capacity
24-
"""
2519
if capacity is None:
2620
return False
2721
if capacity == 0:
@@ -37,10 +31,6 @@ def _parent_capacity_full(course: Course) -> bool:
3731

3832

3933
def _child_capacity_full(child: Course) -> bool:
40-
"""
41-
A child seat is taken when a Registration that includes this child is
42-
APPROVED or FINAL.
43-
"""
4434
taken = RegistrationItem.objects.filter(
4535
child_course=child,
4636
registration__status__in=[Registration.Status.APPROVED, Registration.Status.FINAL],
@@ -71,13 +61,10 @@ def submit_registration(
7161
resume_url: str | None = None,
7262
) -> Registration:
7363
"""
74-
Create/replace a user's registration for a parent course with selected children.
75-
76-
Behavior:
77-
- Do NOT block when full.
78-
- If parent OR any child is full => set RESERVED and force approval (even if requires_approval=False).
79-
- Else QUEUED.
80-
- If no approval required AND status is QUEUED => auto-approve/finalize (free) or create payment link.
64+
Do NOT block when full.
65+
If parent OR any child is full => set RESERVED and force approval.
66+
Else QUEUED.
67+
If no approval required AND status is QUEUED => auto-approve/finalize (free) or create payment link.
8168
"""
8269
if (not user.is_authenticated) or (not getattr(user, "is_email_verified", False)):
8370
raise CustomAPIException(
@@ -88,7 +75,6 @@ def submit_registration(
8875

8976
child_ids = child_ids or []
9077

91-
# Validate selected children are active and actually belong to this course
9278
valid_children_qs = course.children.filter(is_active=True, id__in=child_ids)
9379
valid_children = list(valid_children_qs)
9480
if len(valid_children) != len(child_ids):
@@ -98,13 +84,11 @@ def submit_registration(
9884
status_code=status.HTTP_400_BAD_REQUEST,
9985
)
10086

101-
# Capacity snapshot
10287
parent_full = _parent_capacity_full(course)
10388
full_children = [c for c in valid_children if _child_capacity_full(c)]
10489
any_child_full = bool(full_children)
105-
forced_waitlist = parent_full or any_child_full # => RESERVED + approval flow
90+
forced_waitlist = parent_full or any_child_full
10691

107-
# Create or reuse the registration
10892
reg, created = Registration.objects.get_or_create(course=course, user=user)
10993
if (not created) and reg.status in [Registration.Status.FINAL]:
11094
raise CustomAPIException(
@@ -113,16 +97,12 @@ def submit_registration(
11397
status_code=status.HTTP_409_CONFLICT,
11498
)
11599

116-
# Update main fields
117100
reg.resume_url = resume_url or reg.resume_url
118101
reg.submitted_at = timezone.now()
119102
reg.rejection_reason = ""
120-
121-
# Initial status
122103
reg.status = Registration.Status.RESERVED if forced_waitlist else Registration.Status.QUEUED
123104
reg.save()
124105

125-
# Save extra applicant data (best-effort)
126106
if extra_updates:
127107
extra, _ = UserExtraData.objects.get_or_create(user=user)
128108
extra.answers = {**(extra.answers or {}), **extra_updates}
@@ -135,7 +115,6 @@ def submit_registration(
135115
extra.codeforces_handle = str(extra_updates["codeforces_handle"])[:64]
136116
extra.save()
137117

138-
# Refresh children selections
139118
RegistrationItem.objects.filter(registration=reg).delete()
140119
for c in valid_children:
141120
RegistrationItem.objects.create(
@@ -144,7 +123,6 @@ def submit_registration(
144123
price=c.price,
145124
)
146125

147-
# Notify submit
148126
send_status_change_email(
149127
to=user.email,
150128
status_code="COURSE_REQUEST_SUBMITTED",
@@ -155,7 +133,6 @@ def submit_registration(
155133
},
156134
)
157135

158-
# If any capacity is full, or course/child requires approval, do NOT auto-progress.
159136
requires_approval = bool(getattr(course, "requires_approval", False)) or any(
160137
getattr(c, "requires_approval", False) for c in valid_children
161138
) or forced_waitlist
@@ -175,22 +152,36 @@ def set_status_approved(
175152
override_amount: int | None = None,
176153
description: str | None = None,
177154
) -> Registration:
155+
"""
156+
Move a registration to APPROVED and create a payment link.
157+
Now pushes a *bundle* target_id and attaches reg_id in metadata so
158+
verify step can finalize this registration immediately.
159+
"""
178160
reg.status = Registration.Status.APPROVED
179161

180162
if payment_link is None:
181163
amount = override_amount if override_amount is not None else _compute_total_amount(reg)
182-
try:
183-
payment_result = initiate_payment_for_target(
184-
user=reg.user,
185-
target_type=Payment.TargetType.COURSE, # keep existing target type
186-
target_id=reg.course.id,
187-
amount=amount,
188-
description=description or _compose_description(reg),
189-
)
190-
except CustomAPIException:
191-
# Bubble up payment-layer codes (e.g., PAY_INIT_FAILED / PAY_GATEWAY_REFUSED)
192-
raise
193-
reg.payment_link = payment_result.url
164+
165+
parent_id = str(reg.course.id)
166+
child_ids = [str(it.child_course_id) for it in reg.items.all()]
167+
bundle_target_id = ",".join([parent_id, *child_ids]) # string
168+
169+
metadata = {
170+
"reg_id": reg.id,
171+
"parent_course_id": reg.course.id,
172+
"child_course_ids": child_ids,
173+
"bundle": True,
174+
}
175+
176+
result = initiate_payment_for_target(
177+
user=reg.user,
178+
target_type="COURSE_BUNDLE",
179+
target_id=bundle_target_id,
180+
amount=amount,
181+
description=description or _compose_description(reg),
182+
extra_metadata=metadata,
183+
)
184+
reg.payment_link = result.url
194185
else:
195186
reg.payment_link = payment_link
196187

@@ -243,12 +234,8 @@ def set_status_rejected(reg: Registration, *, actor: User | None = None) -> Regi
243234

244235

245236
def _auto_progress_to_payment(reg: Registration) -> None:
246-
"""
247-
Auto-approve or auto-finalize (if free) when approval is NOT required and status is QUEUED.
248-
"""
249237
total = _compute_total_amount(reg)
250238
if total <= 0:
251-
# Free registration — finalize directly
252239
reg.status = Registration.Status.FINAL
253240
reg.decided_at = timezone.now()
254241
reg.payment_link = ""

0 commit comments

Comments
 (0)