TorbatYar/backend/services/loyalty/app/validators/membership.py
Mortezakoohjani e41ecfad4c Sync platform docs, infra, and module services with Accounting integration.
Include Loyalty/Communication/Sports Center backends and registry updates alongside production nginx and compose wiring.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 22:35:23 +03:30

146 lines
4.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Phase 7.1 membership lifecycle validators."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from shared.exceptions import AppError
from app.models.types import MemberStatus, MembershipLifecycleAction
DEFAULT_TERM_DAYS = 365
# Canonical transitions for the Membership Engine.
ALLOWED_TRANSITIONS: dict[MembershipLifecycleAction, set[MemberStatus]] = {
MembershipLifecycleAction.ENROLL: {MemberStatus.PENDING},
MembershipLifecycleAction.ACTIVATE: {MemberStatus.PENDING, MemberStatus.EXPIRED},
MembershipLifecycleAction.RENEW: {
MemberStatus.ACTIVE,
MemberStatus.EXPIRED,
MemberStatus.FROZEN,
MemberStatus.SUSPENDED,
},
MembershipLifecycleAction.FREEZE: {MemberStatus.ACTIVE},
MembershipLifecycleAction.RESUME: {
MemberStatus.FROZEN,
MemberStatus.SUSPENDED,
},
MembershipLifecycleAction.CANCEL: {
MemberStatus.PENDING,
MemberStatus.ACTIVE,
MemberStatus.FROZEN,
MemberStatus.SUSPENDED,
MemberStatus.EXPIRED,
},
MembershipLifecycleAction.EXPIRE: {
MemberStatus.ACTIVE,
MemberStatus.FROZEN,
MemberStatus.SUSPENDED,
},
MembershipLifecycleAction.TRANSFER: {
MemberStatus.ACTIVE,
MemberStatus.FROZEN,
MemberStatus.SUSPENDED,
},
}
TERMINAL_STATUSES = frozenset(
{
MemberStatus.CANCELLED,
MemberStatus.TRANSFERRED,
MemberStatus.CLOSED,
}
)
ACTIVE_MEMBER_STATUSES = frozenset(
{
MemberStatus.PENDING,
MemberStatus.ACTIVE,
MemberStatus.FROZEN,
MemberStatus.SUSPENDED,
MemberStatus.EXPIRED,
}
)
def ensure_lifecycle_transition(
*,
action: MembershipLifecycleAction,
current: MemberStatus,
) -> None:
if current in TERMINAL_STATUSES:
raise AppError(
"عضویت در وضعیت پایانی است و قابل تغییر نیست",
status_code=409,
error_code="membership_terminal",
details={"status": current.value, "action": action.value},
)
allowed = ALLOWED_TRANSITIONS.get(action, set())
if current not in allowed:
raise AppError(
"انتقال وضعیت عضویت مجاز نیست",
status_code=409,
error_code="invalid_lifecycle_transition",
details={
"from_status": current.value,
"action": action.value,
"allowed_from": sorted(s.value for s in allowed),
},
)
def target_status_for(action: MembershipLifecycleAction) -> MemberStatus:
mapping = {
MembershipLifecycleAction.ENROLL: MemberStatus.ACTIVE,
MembershipLifecycleAction.ACTIVATE: MemberStatus.ACTIVE,
MembershipLifecycleAction.RENEW: MemberStatus.ACTIVE,
MembershipLifecycleAction.FREEZE: MemberStatus.FROZEN,
MembershipLifecycleAction.RESUME: MemberStatus.ACTIVE,
MembershipLifecycleAction.CANCEL: MemberStatus.CANCELLED,
MembershipLifecycleAction.EXPIRE: MemberStatus.EXPIRED,
MembershipLifecycleAction.TRANSFER: MemberStatus.TRANSFERRED,
}
return mapping[action]
def compute_expiry(
*,
from_when: datetime | None = None,
term_days: int = DEFAULT_TERM_DAYS,
explicit: datetime | None = None,
) -> datetime:
if explicit is not None:
if explicit.tzinfo is None:
explicit = explicit.replace(tzinfo=timezone.utc)
return explicit
if term_days < 1:
raise AppError(
"مدت عضویت باید حداقل ۱ روز باشد",
status_code=422,
error_code="invalid_term_days",
)
base = from_when or datetime.now(timezone.utc)
if base.tzinfo is None:
base = base.replace(tzinfo=timezone.utc)
return base + timedelta(days=term_days)
def validate_reason(reason: str | None, *, required: bool = False, field: str = "reason") -> str | None:
if reason is None or not str(reason).strip():
if required:
raise AppError(
f"{field} الزامی است",
status_code=422,
error_code="validation_error",
details={"field": field},
)
return None
cleaned = str(reason).strip()
if len(cleaned) > 500:
raise AppError(
f"{field} بیش از حد طولانی است",
status_code=422,
error_code="validation_error",
details={"field": field},
)
return cleaned