Include Loyalty/Communication/Sports Center backends and registry updates alongside production nginx and compose wiring. Co-authored-by: Cursor <cursoragent@cursor.com>
1261 lines
48 KiB
Python
1261 lines
48 KiB
Python
"""Loyalty foundation application services — Phase 7.0."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime, timezone
|
|
from enum import Enum
|
|
from typing import Any
|
|
from uuid import UUID, uuid4
|
|
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.events.publisher import TransactionalEventPublisher
|
|
from app.events.types import LoyaltyEventType
|
|
from app.models.foundation import (
|
|
Campaign,
|
|
LoyaltyProgram,
|
|
Member,
|
|
MembershipLifecycleEvent,
|
|
MembershipTier,
|
|
PointAccount,
|
|
Reward,
|
|
)
|
|
from app.models.types import AuditAction, MemberStatus, MembershipLifecycleAction, PointAccountStatus
|
|
from app.repositories.foundation import (
|
|
CampaignRepository,
|
|
LoyaltyProgramRepository,
|
|
MemberRepository,
|
|
MembershipLifecycleEventRepository,
|
|
MembershipTierRepository,
|
|
PointAccountRepository,
|
|
RewardRepository,
|
|
)
|
|
from app.schemas.foundation import (
|
|
CampaignCreate,
|
|
CampaignUpdate,
|
|
LoyaltyProgramCreate,
|
|
LoyaltyProgramUpdate,
|
|
MemberCreate,
|
|
MemberEnrollRequest,
|
|
MemberUpdate,
|
|
MembershipTierCreate,
|
|
MembershipTierUpdate,
|
|
PointAccountCreate,
|
|
PointAccountUpdate,
|
|
RewardCreate,
|
|
RewardUpdate,
|
|
)
|
|
from app.services.audit_service import AuditService
|
|
from app.validators import (
|
|
ensure_optimistic_version,
|
|
forbid_direct_balance_mutation,
|
|
release_unique_token,
|
|
validate_campaign_dates,
|
|
validate_campaign_status,
|
|
validate_code,
|
|
validate_currency_code,
|
|
validate_email,
|
|
validate_member_status,
|
|
validate_non_empty,
|
|
validate_non_negative_int,
|
|
validate_phone,
|
|
validate_point_account_status,
|
|
validate_program_status,
|
|
validate_reward_status,
|
|
validate_reward_type,
|
|
validate_tier_status,
|
|
)
|
|
from app.validators.membership import (
|
|
ACTIVE_MEMBER_STATUSES,
|
|
DEFAULT_TERM_DAYS,
|
|
compute_expiry,
|
|
ensure_lifecycle_transition,
|
|
target_status_for,
|
|
)
|
|
from shared.exceptions import AppError, NotFoundError
|
|
from shared.security import CurrentUser
|
|
|
|
|
|
def _apply_update(entity, data: dict) -> None:
|
|
for key, value in data.items():
|
|
setattr(entity, key, value)
|
|
|
|
|
|
def _reject_nulls(data: dict, fields: set[str]) -> None:
|
|
for field in fields:
|
|
if field in data and data[field] is None:
|
|
raise AppError(
|
|
f"{field} نمیتواند null باشد",
|
|
status_code=422,
|
|
error_code="null_not_allowed",
|
|
details={"field": field},
|
|
)
|
|
|
|
|
|
def _jsonable_changes(data: dict[str, Any]) -> dict[str, Any]:
|
|
out: dict[str, Any] = {}
|
|
for key, value in data.items():
|
|
if isinstance(value, UUID):
|
|
out[key] = str(value)
|
|
elif isinstance(value, (datetime, date)):
|
|
out[key] = value.isoformat()
|
|
elif isinstance(value, Enum):
|
|
out[key] = value.value
|
|
else:
|
|
out[key] = value
|
|
return out
|
|
|
|
|
|
def _next_membership_number() -> str:
|
|
return f"MBR-{uuid4().hex[:10].upper()}"
|
|
|
|
|
|
def _next_account_number() -> str:
|
|
return f"PA-{uuid4().hex[:10].upper()}"
|
|
|
|
|
|
async def _clear_other_defaults(
|
|
session: AsyncSession, tenant_id: UUID, keep_id: UUID | None = None
|
|
) -> None:
|
|
from sqlalchemy import update
|
|
|
|
stmt = (
|
|
update(LoyaltyProgram)
|
|
.where(
|
|
LoyaltyProgram.tenant_id == tenant_id,
|
|
LoyaltyProgram.is_default.is_(True),
|
|
LoyaltyProgram.is_deleted.is_(False),
|
|
)
|
|
.values(is_default=False)
|
|
)
|
|
if keep_id is not None:
|
|
stmt = stmt.where(LoyaltyProgram.id != keep_id)
|
|
await session.execute(stmt)
|
|
|
|
|
|
def _map_integrity(*, error_code: str, message: str) -> AppError:
|
|
return AppError(message, status_code=409, error_code=error_code)
|
|
|
|
|
|
class LoyaltyProgramService:
|
|
def __init__(
|
|
self,
|
|
session: AsyncSession,
|
|
publisher: TransactionalEventPublisher | None = None,
|
|
) -> None:
|
|
self.session = session
|
|
self.repo = LoyaltyProgramRepository(session)
|
|
self.members = MemberRepository(session)
|
|
self.audit = AuditService(session)
|
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
|
|
async def create(
|
|
self,
|
|
tenant_id: UUID,
|
|
body: LoyaltyProgramCreate,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> LoyaltyProgram:
|
|
actor_id = actor.user_id if actor else None
|
|
code = validate_code(body.code)
|
|
name = validate_non_empty(body.name, field="name")
|
|
currency = validate_currency_code(body.currency_code)
|
|
status = validate_program_status(body.status)
|
|
if await self.repo.get_by_code(tenant_id, code) is not None:
|
|
raise AppError(
|
|
"کد برنامه وفاداری تکراری است",
|
|
status_code=409,
|
|
error_code="program_code_exists",
|
|
)
|
|
if body.is_default:
|
|
await _clear_other_defaults(self.session, tenant_id)
|
|
entity = LoyaltyProgram(
|
|
tenant_id=tenant_id,
|
|
code=code,
|
|
name=name,
|
|
description=body.description,
|
|
status=status,
|
|
points_currency_name=validate_non_empty(
|
|
body.points_currency_name, field="points_currency_name"
|
|
),
|
|
currency_code=currency,
|
|
is_default=body.is_default,
|
|
settings=body.settings,
|
|
created_by=actor_id,
|
|
updated_by=actor_id,
|
|
)
|
|
try:
|
|
await self.repo.add(entity)
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="loyalty_program",
|
|
entity_id=entity.id,
|
|
action=AuditAction.CREATE,
|
|
actor_user_id=actor_id,
|
|
message=f"Program {entity.code} created",
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.PROGRAM_CREATED,
|
|
aggregate_type="loyalty_program",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"code": entity.code, "status": entity.status.value},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
except IntegrityError:
|
|
await self.session.rollback()
|
|
raise _map_integrity(
|
|
error_code="program_code_exists",
|
|
message="کد برنامه وفاداری تکراری است",
|
|
)
|
|
return entity
|
|
|
|
async def update(
|
|
self,
|
|
tenant_id: UUID,
|
|
program_id: UUID,
|
|
body: LoyaltyProgramUpdate,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> LoyaltyProgram:
|
|
entity = await self.get(tenant_id, program_id)
|
|
ensure_optimistic_version(entity.version, body.version)
|
|
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
|
_reject_nulls(
|
|
data,
|
|
{"name", "status", "points_currency_name", "currency_code", "is_default"},
|
|
)
|
|
if "name" in data:
|
|
data["name"] = validate_non_empty(data["name"], field="name")
|
|
if "currency_code" in data:
|
|
data["currency_code"] = validate_currency_code(data["currency_code"])
|
|
if "status" in data:
|
|
data["status"] = validate_program_status(data["status"])
|
|
if "points_currency_name" in data:
|
|
data["points_currency_name"] = validate_non_empty(
|
|
data["points_currency_name"], field="points_currency_name"
|
|
)
|
|
if data.get("is_default") is True:
|
|
await _clear_other_defaults(self.session, tenant_id, keep_id=entity.id)
|
|
_apply_update(entity, data)
|
|
entity.version += 1
|
|
entity.updated_by = actor.user_id if actor else None
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="loyalty_program",
|
|
entity_id=entity.id,
|
|
action=AuditAction.UPDATE,
|
|
actor_user_id=entity.updated_by,
|
|
changes=_jsonable_changes(data),
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.PROGRAM_UPDATED,
|
|
aggregate_type="loyalty_program",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"code": entity.code, "status": entity.status.value},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
return entity
|
|
|
|
async def get(self, tenant_id: UUID, program_id: UUID) -> LoyaltyProgram:
|
|
entity = await self.repo.get(tenant_id, program_id)
|
|
if entity is None:
|
|
raise NotFoundError("برنامه وفاداری یافت نشد", error_code="program_not_found")
|
|
return entity
|
|
|
|
async def list(self, tenant_id: UUID, *, offset: int, limit: int):
|
|
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
|
|
|
async def soft_delete(
|
|
self,
|
|
tenant_id: UUID,
|
|
program_id: UUID,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> LoyaltyProgram:
|
|
entity = await self.get(tenant_id, program_id)
|
|
blocking = await self.members.count_blocking_for_program(
|
|
tenant_id, program_id, list(ACTIVE_MEMBER_STATUSES)
|
|
)
|
|
if blocking:
|
|
raise AppError(
|
|
"برنامه دارای اعضای فعال است و قابل حذف نیست",
|
|
status_code=409,
|
|
error_code="program_has_active_members",
|
|
details={"active_member_count": blocking},
|
|
)
|
|
entity.code = release_unique_token(entity.code, entity.id)
|
|
await self.repo.soft_delete(
|
|
entity, deleted_by=actor.user_id if actor else None
|
|
)
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="loyalty_program",
|
|
entity_id=entity.id,
|
|
action=AuditAction.DELETE,
|
|
actor_user_id=actor.user_id if actor else None,
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.PROGRAM_DELETED,
|
|
aggregate_type="loyalty_program",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"code": entity.code},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
return entity
|
|
|
|
|
|
class MembershipTierService:
|
|
def __init__(
|
|
self,
|
|
session: AsyncSession,
|
|
publisher: TransactionalEventPublisher | None = None,
|
|
) -> None:
|
|
self.session = session
|
|
self.repo = MembershipTierRepository(session)
|
|
self.programs = LoyaltyProgramRepository(session)
|
|
self.audit = AuditService(session)
|
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
|
|
async def create(
|
|
self,
|
|
tenant_id: UUID,
|
|
body: MembershipTierCreate,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> MembershipTier:
|
|
actor_id = actor.user_id if actor else None
|
|
if await self.programs.get(tenant_id, body.program_id) is None:
|
|
raise NotFoundError("برنامه وفاداری یافت نشد", error_code="program_not_found")
|
|
code = validate_code(body.code)
|
|
name = validate_non_empty(body.name, field="name")
|
|
status = validate_tier_status(body.status)
|
|
min_points = validate_non_negative_int(body.min_points, field="min_points")
|
|
if await self.repo.get_by_code(tenant_id, body.program_id, code) is not None:
|
|
raise AppError(
|
|
"کد سطح عضویت تکراری است",
|
|
status_code=409,
|
|
error_code="tier_code_exists",
|
|
)
|
|
entity = MembershipTier(
|
|
tenant_id=tenant_id,
|
|
program_id=body.program_id,
|
|
code=code,
|
|
name=name,
|
|
description=body.description,
|
|
rank=body.rank,
|
|
min_points=min_points,
|
|
status=status,
|
|
benefits=body.benefits,
|
|
rules=body.rules,
|
|
created_by=actor_id,
|
|
updated_by=actor_id,
|
|
)
|
|
try:
|
|
await self.repo.add(entity)
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="membership_tier",
|
|
entity_id=entity.id,
|
|
action=AuditAction.CREATE,
|
|
actor_user_id=actor_id,
|
|
message=f"Tier {entity.code} created",
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.TIER_CREATED,
|
|
aggregate_type="membership_tier",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"code": entity.code, "program_id": str(entity.program_id)},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
except IntegrityError:
|
|
await self.session.rollback()
|
|
raise _map_integrity(
|
|
error_code="tier_code_exists",
|
|
message="کد سطح عضویت تکراری است",
|
|
)
|
|
return entity
|
|
|
|
async def update(
|
|
self,
|
|
tenant_id: UUID,
|
|
tier_id: UUID,
|
|
body: MembershipTierUpdate,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> MembershipTier:
|
|
entity = await self.get(tenant_id, tier_id)
|
|
data = body.model_dump(exclude_unset=True)
|
|
_reject_nulls(data, {"name", "rank", "min_points", "status"})
|
|
if "name" in data:
|
|
data["name"] = validate_non_empty(data["name"], field="name")
|
|
if "status" in data:
|
|
data["status"] = validate_tier_status(data["status"])
|
|
if "min_points" in data:
|
|
data["min_points"] = validate_non_negative_int(
|
|
data["min_points"], field="min_points"
|
|
)
|
|
if "rank" in data:
|
|
data["rank"] = validate_non_negative_int(data["rank"], field="rank")
|
|
_apply_update(entity, data)
|
|
entity.updated_by = actor.user_id if actor else None
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="membership_tier",
|
|
entity_id=entity.id,
|
|
action=AuditAction.UPDATE,
|
|
actor_user_id=entity.updated_by,
|
|
changes=_jsonable_changes(data),
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.TIER_UPDATED,
|
|
aggregate_type="membership_tier",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"code": entity.code},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
return entity
|
|
|
|
async def get(self, tenant_id: UUID, tier_id: UUID) -> MembershipTier:
|
|
entity = await self.repo.get(tenant_id, tier_id)
|
|
if entity is None:
|
|
raise NotFoundError("سطح عضویت یافت نشد", error_code="tier_not_found")
|
|
return entity
|
|
|
|
async def list(self, tenant_id: UUID, *, offset: int, limit: int):
|
|
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
|
|
|
async def list_by_program(
|
|
self, tenant_id: UUID, program_id: UUID, *, offset: int, limit: int
|
|
):
|
|
return await self.repo.list_by_program(
|
|
tenant_id, program_id, offset=offset, limit=limit
|
|
)
|
|
|
|
async def soft_delete(
|
|
self,
|
|
tenant_id: UUID,
|
|
tier_id: UUID,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> MembershipTier:
|
|
entity = await self.get(tenant_id, tier_id)
|
|
entity.code = release_unique_token(entity.code, entity.id)
|
|
await self.repo.soft_delete(
|
|
entity, deleted_by=actor.user_id if actor else None
|
|
)
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="membership_tier",
|
|
entity_id=entity.id,
|
|
action=AuditAction.DELETE,
|
|
actor_user_id=actor.user_id if actor else None,
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.TIER_DELETED,
|
|
aggregate_type="membership_tier",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"code": entity.code},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
return entity
|
|
|
|
|
|
class MemberService:
|
|
def __init__(
|
|
self,
|
|
session: AsyncSession,
|
|
publisher: TransactionalEventPublisher | None = None,
|
|
) -> None:
|
|
self.session = session
|
|
self.repo = MemberRepository(session)
|
|
self.programs = LoyaltyProgramRepository(session)
|
|
self.tiers = MembershipTierRepository(session)
|
|
self.audit = AuditService(session)
|
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
|
|
async def create(
|
|
self,
|
|
tenant_id: UUID,
|
|
body: MemberCreate,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> Member:
|
|
actor_id = actor.user_id if actor else None
|
|
if await self.programs.get(tenant_id, body.program_id) is None:
|
|
raise NotFoundError("برنامه وفاداری یافت نشد", error_code="program_not_found")
|
|
if body.tier_id is not None:
|
|
tier = await self.tiers.get(tenant_id, body.tier_id)
|
|
if tier is None or tier.program_id != body.program_id:
|
|
raise NotFoundError("سطح عضویت یافت نشد", error_code="tier_not_found")
|
|
display_name = validate_non_empty(body.display_name, field="display_name")
|
|
email = validate_email(body.email)
|
|
mobile = validate_phone(body.mobile, field="mobile")
|
|
status = validate_member_status(body.status)
|
|
membership_number = (
|
|
validate_code(body.membership_number, field="membership_number")
|
|
if body.membership_number
|
|
else _next_membership_number()
|
|
)
|
|
if await self.repo.get_by_membership_number(tenant_id, membership_number):
|
|
raise AppError(
|
|
"شماره عضویت تکراری است",
|
|
status_code=409,
|
|
error_code="membership_number_exists",
|
|
)
|
|
if body.external_customer_ref:
|
|
existing_ref = await self.repo.get_by_external_ref(
|
|
tenant_id, body.program_id, body.external_customer_ref
|
|
)
|
|
if existing_ref is not None:
|
|
raise AppError(
|
|
"شناسه خارجی مشتری تکراری است",
|
|
status_code=409,
|
|
error_code="external_customer_ref_exists",
|
|
)
|
|
enrolled_at = None
|
|
activated_at = None
|
|
membership_started_at = None
|
|
membership_expires_at = None
|
|
if body.enroll:
|
|
status = MemberStatus.ACTIVE
|
|
enrolled_at = datetime.now(timezone.utc)
|
|
activated_at = enrolled_at
|
|
membership_started_at = enrolled_at
|
|
membership_expires_at = compute_expiry(from_when=enrolled_at)
|
|
entity = Member(
|
|
tenant_id=tenant_id,
|
|
program_id=body.program_id,
|
|
membership_number=membership_number,
|
|
external_customer_ref=body.external_customer_ref,
|
|
display_name=display_name,
|
|
email=email,
|
|
mobile=mobile,
|
|
status=status,
|
|
tier_id=body.tier_id,
|
|
enrolled_at=enrolled_at,
|
|
activated_at=activated_at,
|
|
membership_started_at=membership_started_at,
|
|
membership_expires_at=membership_expires_at,
|
|
profile=body.profile,
|
|
created_by=actor_id,
|
|
updated_by=actor_id,
|
|
)
|
|
try:
|
|
await self.repo.add(entity)
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="member",
|
|
entity_id=entity.id,
|
|
action=AuditAction.CREATE,
|
|
actor_user_id=actor_id,
|
|
message=f"Member {entity.membership_number} created",
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.MEMBER_CREATED,
|
|
aggregate_type="member",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={
|
|
"membership_number": entity.membership_number,
|
|
"status": entity.status.value,
|
|
},
|
|
)
|
|
if body.enroll:
|
|
self.lifecycle = MembershipLifecycleEventRepository(self.session)
|
|
await self.lifecycle.add(
|
|
MembershipLifecycleEvent(
|
|
tenant_id=tenant_id,
|
|
member_id=entity.id,
|
|
action=MembershipLifecycleAction.ENROLL,
|
|
from_status=MemberStatus.PENDING.value,
|
|
to_status=MemberStatus.ACTIVE.value,
|
|
reason=None,
|
|
metadata_json={"term_days": DEFAULT_TERM_DAYS},
|
|
actor_user_id=actor_id,
|
|
created_at=enrolled_at,
|
|
)
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.MEMBER_ENROLLED,
|
|
aggregate_type="member",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"membership_number": entity.membership_number},
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.MEMBER_ACTIVATED,
|
|
aggregate_type="member",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={
|
|
"membership_number": entity.membership_number,
|
|
"status": entity.status.value,
|
|
},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
except IntegrityError:
|
|
await self.session.rollback()
|
|
raise _map_integrity(
|
|
error_code="member_conflict",
|
|
message="عضو با این شناسهها قابل ایجاد نیست",
|
|
)
|
|
return entity
|
|
|
|
async def update(
|
|
self,
|
|
tenant_id: UUID,
|
|
member_id: UUID,
|
|
body: MemberUpdate,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> Member:
|
|
entity = await self.get(tenant_id, member_id)
|
|
ensure_optimistic_version(entity.version, body.version)
|
|
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
|
_reject_nulls(data, {"display_name", "status"})
|
|
if "display_name" in data:
|
|
data["display_name"] = validate_non_empty(
|
|
data["display_name"], field="display_name"
|
|
)
|
|
if "email" in data:
|
|
data["email"] = validate_email(data["email"])
|
|
if "mobile" in data:
|
|
data["mobile"] = validate_phone(data["mobile"], field="mobile")
|
|
if "status" in data:
|
|
data["status"] = validate_member_status(data["status"])
|
|
if "tier_id" in data and data["tier_id"] is not None:
|
|
tier = await self.tiers.get(tenant_id, data["tier_id"])
|
|
if tier is None or tier.program_id != entity.program_id:
|
|
raise NotFoundError("سطح عضویت یافت نشد", error_code="tier_not_found")
|
|
_apply_update(entity, data)
|
|
entity.version += 1
|
|
entity.updated_by = actor.user_id if actor else None
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="member",
|
|
entity_id=entity.id,
|
|
action=AuditAction.UPDATE,
|
|
actor_user_id=entity.updated_by,
|
|
changes=_jsonable_changes(data),
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.MEMBER_UPDATED,
|
|
aggregate_type="member",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"status": entity.status.value},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
return entity
|
|
|
|
async def enroll(
|
|
self,
|
|
tenant_id: UUID,
|
|
member_id: UUID,
|
|
body: MemberEnrollRequest,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> Member:
|
|
entity = await self.get(tenant_id, member_id)
|
|
action = MembershipLifecycleAction.ENROLL
|
|
ensure_lifecycle_transition(action=action, current=entity.status)
|
|
if body.tier_id is not None:
|
|
tier = await self.tiers.get(tenant_id, body.tier_id)
|
|
if tier is None or tier.program_id != entity.program_id:
|
|
raise NotFoundError("سطح عضویت یافت نشد", error_code="tier_not_found")
|
|
entity.tier_id = body.tier_id
|
|
from_status = entity.status
|
|
to_status = target_status_for(action)
|
|
now = datetime.now(timezone.utc)
|
|
entity.status = to_status
|
|
entity.enrolled_at = entity.enrolled_at or now
|
|
entity.activated_at = entity.activated_at or now
|
|
entity.membership_started_at = entity.membership_started_at or now
|
|
entity.membership_expires_at = compute_expiry(
|
|
from_when=now,
|
|
term_days=body.term_days or DEFAULT_TERM_DAYS,
|
|
explicit=body.expires_at,
|
|
)
|
|
entity.version += 1
|
|
entity.updated_by = actor.user_id if actor else None
|
|
await MembershipLifecycleEventRepository(self.session).add(
|
|
MembershipLifecycleEvent(
|
|
tenant_id=tenant_id,
|
|
member_id=entity.id,
|
|
action=action,
|
|
from_status=from_status.value,
|
|
to_status=to_status.value,
|
|
reason=body.reason,
|
|
metadata_json={"term_days": body.term_days or DEFAULT_TERM_DAYS},
|
|
actor_user_id=entity.updated_by,
|
|
created_at=now,
|
|
)
|
|
)
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="member",
|
|
entity_id=entity.id,
|
|
action=AuditAction.ENROLL,
|
|
actor_user_id=entity.updated_by,
|
|
message=f"Member {entity.membership_number} enrolled",
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.MEMBER_ENROLLED,
|
|
aggregate_type="member",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"membership_number": entity.membership_number},
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.MEMBER_ACTIVATED,
|
|
aggregate_type="member",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={
|
|
"membership_number": entity.membership_number,
|
|
"status": entity.status.value,
|
|
},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
return entity
|
|
|
|
async def get(self, tenant_id: UUID, member_id: UUID) -> Member:
|
|
entity = await self.repo.get(tenant_id, member_id)
|
|
if entity is None:
|
|
raise NotFoundError("عضو یافت نشد", error_code="member_not_found")
|
|
return entity
|
|
|
|
async def list(self, tenant_id: UUID, *, offset: int, limit: int):
|
|
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
|
|
|
async def soft_delete(
|
|
self,
|
|
tenant_id: UUID,
|
|
member_id: UUID,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> Member:
|
|
entity = await self.get(tenant_id, member_id)
|
|
entity.membership_number = release_unique_token(
|
|
entity.membership_number, entity.id
|
|
)
|
|
if entity.external_customer_ref:
|
|
entity.external_customer_ref = release_unique_token(
|
|
entity.external_customer_ref, entity.id
|
|
)
|
|
await self.repo.soft_delete(
|
|
entity, deleted_by=actor.user_id if actor else None
|
|
)
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="member",
|
|
entity_id=entity.id,
|
|
action=AuditAction.DELETE,
|
|
actor_user_id=actor.user_id if actor else None,
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.MEMBER_DELETED,
|
|
aggregate_type="member",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"membership_number": entity.membership_number},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
return entity
|
|
|
|
|
|
class PointAccountService:
|
|
def __init__(
|
|
self,
|
|
session: AsyncSession,
|
|
publisher: TransactionalEventPublisher | None = None,
|
|
) -> None:
|
|
self.session = session
|
|
self.repo = PointAccountRepository(session)
|
|
self.members = MemberRepository(session)
|
|
self.programs = LoyaltyProgramRepository(session)
|
|
self.audit = AuditService(session)
|
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
|
|
async def create(
|
|
self,
|
|
tenant_id: UUID,
|
|
body: PointAccountCreate,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> PointAccount:
|
|
forbid_direct_balance_mutation(body.model_dump())
|
|
actor_id = actor.user_id if actor else None
|
|
program = await self.programs.get(tenant_id, body.program_id)
|
|
if program is None:
|
|
raise NotFoundError("برنامه وفاداری یافت نشد", error_code="program_not_found")
|
|
member = await self.members.get(tenant_id, body.member_id)
|
|
if member is None or member.program_id != body.program_id:
|
|
raise NotFoundError("عضو یافت نشد", error_code="member_not_found")
|
|
if await self.repo.get_by_member(tenant_id, body.member_id) is not None:
|
|
raise AppError(
|
|
"حساب امتیاز برای این عضو وجود دارد",
|
|
status_code=409,
|
|
error_code="point_account_exists",
|
|
)
|
|
account_number = (
|
|
validate_code(body.account_number, field="account_number")
|
|
if body.account_number
|
|
else _next_account_number()
|
|
)
|
|
if await self.repo.get_by_account_number(tenant_id, account_number):
|
|
raise AppError(
|
|
"شماره حساب امتیاز تکراری است",
|
|
status_code=409,
|
|
error_code="account_number_exists",
|
|
)
|
|
entity = PointAccount(
|
|
tenant_id=tenant_id,
|
|
program_id=body.program_id,
|
|
member_id=body.member_id,
|
|
account_number=account_number,
|
|
status=validate_point_account_status(body.status),
|
|
currency_name=validate_non_empty(
|
|
body.currency_name, field="currency_name"
|
|
),
|
|
created_by=actor_id,
|
|
updated_by=actor_id,
|
|
)
|
|
try:
|
|
await self.repo.add(entity)
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="point_account",
|
|
entity_id=entity.id,
|
|
action=AuditAction.CREATE,
|
|
actor_user_id=actor_id,
|
|
message=f"Point account {entity.account_number} opened",
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.POINT_ACCOUNT_OPENED,
|
|
aggregate_type="point_account",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={
|
|
"account_number": entity.account_number,
|
|
"member_id": str(entity.member_id),
|
|
},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
except IntegrityError:
|
|
await self.session.rollback()
|
|
raise _map_integrity(
|
|
error_code="point_account_conflict",
|
|
message="حساب امتیاز قابل ایجاد نیست",
|
|
)
|
|
return entity
|
|
|
|
async def update(
|
|
self,
|
|
tenant_id: UUID,
|
|
account_id: UUID,
|
|
body: PointAccountUpdate,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> PointAccount:
|
|
forbid_direct_balance_mutation(body.model_dump())
|
|
entity = await self.get(tenant_id, account_id)
|
|
ensure_optimistic_version(entity.version, body.version)
|
|
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
|
_reject_nulls(data, {"status", "currency_name"})
|
|
if "status" in data:
|
|
data["status"] = validate_point_account_status(data["status"])
|
|
if "currency_name" in data:
|
|
data["currency_name"] = validate_non_empty(
|
|
data["currency_name"], field="currency_name"
|
|
)
|
|
_apply_update(entity, data)
|
|
entity.version += 1
|
|
entity.updated_by = actor.user_id if actor else None
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="point_account",
|
|
entity_id=entity.id,
|
|
action=AuditAction.UPDATE,
|
|
actor_user_id=entity.updated_by,
|
|
changes=_jsonable_changes(data),
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.POINT_ACCOUNT_UPDATED,
|
|
aggregate_type="point_account",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"status": entity.status.value},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
return entity
|
|
|
|
async def get(self, tenant_id: UUID, account_id: UUID) -> PointAccount:
|
|
entity = await self.repo.get(tenant_id, account_id)
|
|
if entity is None:
|
|
raise NotFoundError(
|
|
"حساب امتیاز یافت نشد", error_code="point_account_not_found"
|
|
)
|
|
return entity
|
|
|
|
async def list(self, tenant_id: UUID, *, offset: int, limit: int):
|
|
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
|
|
|
async def soft_delete(
|
|
self,
|
|
tenant_id: UUID,
|
|
account_id: UUID,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> PointAccount:
|
|
entity = await self.get(tenant_id, account_id)
|
|
entity.account_number = release_unique_token(entity.account_number, entity.id)
|
|
entity.status = PointAccountStatus.CLOSED
|
|
await self.repo.soft_delete(
|
|
entity, deleted_by=actor.user_id if actor else None
|
|
)
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="point_account",
|
|
entity_id=entity.id,
|
|
action=AuditAction.DELETE,
|
|
actor_user_id=actor.user_id if actor else None,
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.POINT_ACCOUNT_DELETED,
|
|
aggregate_type="point_account",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"account_number": entity.account_number},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
return entity
|
|
|
|
|
|
class RewardService:
|
|
def __init__(
|
|
self,
|
|
session: AsyncSession,
|
|
publisher: TransactionalEventPublisher | None = None,
|
|
) -> None:
|
|
self.session = session
|
|
self.repo = RewardRepository(session)
|
|
self.programs = LoyaltyProgramRepository(session)
|
|
self.audit = AuditService(session)
|
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
|
|
async def create(
|
|
self,
|
|
tenant_id: UUID,
|
|
body: RewardCreate,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> Reward:
|
|
actor_id = actor.user_id if actor else None
|
|
if await self.programs.get(tenant_id, body.program_id) is None:
|
|
raise NotFoundError("برنامه وفاداری یافت نشد", error_code="program_not_found")
|
|
code = validate_code(body.code)
|
|
name = validate_non_empty(body.name, field="name")
|
|
if await self.repo.get_by_code(tenant_id, body.program_id, code) is not None:
|
|
raise AppError(
|
|
"کد پاداش تکراری است",
|
|
status_code=409,
|
|
error_code="reward_code_exists",
|
|
)
|
|
entity = Reward(
|
|
tenant_id=tenant_id,
|
|
program_id=body.program_id,
|
|
code=code,
|
|
name=name,
|
|
description=body.description,
|
|
reward_type=validate_reward_type(body.reward_type),
|
|
status=validate_reward_status(body.status),
|
|
points_cost=validate_non_negative_int(body.points_cost, field="points_cost"),
|
|
eligibility_rules=body.eligibility_rules,
|
|
metadata_json=body.metadata_json,
|
|
created_by=actor_id,
|
|
updated_by=actor_id,
|
|
)
|
|
try:
|
|
await self.repo.add(entity)
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="reward",
|
|
entity_id=entity.id,
|
|
action=AuditAction.CREATE,
|
|
actor_user_id=actor_id,
|
|
message=f"Reward {entity.code} created",
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.REWARD_CREATED,
|
|
aggregate_type="reward",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"code": entity.code, "reward_type": entity.reward_type.value},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
except IntegrityError:
|
|
await self.session.rollback()
|
|
raise _map_integrity(
|
|
error_code="reward_code_exists",
|
|
message="کد پاداش تکراری است",
|
|
)
|
|
return entity
|
|
|
|
async def update(
|
|
self,
|
|
tenant_id: UUID,
|
|
reward_id: UUID,
|
|
body: RewardUpdate,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> Reward:
|
|
entity = await self.get(tenant_id, reward_id)
|
|
data = body.model_dump(exclude_unset=True)
|
|
_reject_nulls(data, {"name", "reward_type", "status", "points_cost"})
|
|
if "name" in data:
|
|
data["name"] = validate_non_empty(data["name"], field="name")
|
|
if "reward_type" in data:
|
|
data["reward_type"] = validate_reward_type(data["reward_type"])
|
|
if "status" in data:
|
|
data["status"] = validate_reward_status(data["status"])
|
|
if "points_cost" in data:
|
|
data["points_cost"] = validate_non_negative_int(
|
|
data["points_cost"], field="points_cost"
|
|
)
|
|
_apply_update(entity, data)
|
|
entity.updated_by = actor.user_id if actor else None
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="reward",
|
|
entity_id=entity.id,
|
|
action=AuditAction.UPDATE,
|
|
actor_user_id=entity.updated_by,
|
|
changes=_jsonable_changes(data),
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.REWARD_UPDATED,
|
|
aggregate_type="reward",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"code": entity.code, "status": entity.status.value},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
return entity
|
|
|
|
async def get(self, tenant_id: UUID, reward_id: UUID) -> Reward:
|
|
entity = await self.repo.get(tenant_id, reward_id)
|
|
if entity is None:
|
|
raise NotFoundError("پاداش یافت نشد", error_code="reward_not_found")
|
|
return entity
|
|
|
|
async def list(self, tenant_id: UUID, *, offset: int, limit: int):
|
|
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
|
|
|
async def soft_delete(
|
|
self,
|
|
tenant_id: UUID,
|
|
reward_id: UUID,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> Reward:
|
|
entity = await self.get(tenant_id, reward_id)
|
|
entity.code = release_unique_token(entity.code, entity.id)
|
|
await self.repo.soft_delete(
|
|
entity, deleted_by=actor.user_id if actor else None
|
|
)
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="reward",
|
|
entity_id=entity.id,
|
|
action=AuditAction.DELETE,
|
|
actor_user_id=actor.user_id if actor else None,
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.REWARD_DELETED,
|
|
aggregate_type="reward",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"code": entity.code},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
return entity
|
|
|
|
|
|
class CampaignService:
|
|
def __init__(
|
|
self,
|
|
session: AsyncSession,
|
|
publisher: TransactionalEventPublisher | None = None,
|
|
) -> None:
|
|
self.session = session
|
|
self.repo = CampaignRepository(session)
|
|
self.programs = LoyaltyProgramRepository(session)
|
|
self.audit = AuditService(session)
|
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
|
|
async def create(
|
|
self,
|
|
tenant_id: UUID,
|
|
body: CampaignCreate,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> Campaign:
|
|
actor_id = actor.user_id if actor else None
|
|
if await self.programs.get(tenant_id, body.program_id) is None:
|
|
raise NotFoundError("برنامه وفاداری یافت نشد", error_code="program_not_found")
|
|
code = validate_code(body.code)
|
|
name = validate_non_empty(body.name, field="name")
|
|
starts_on, ends_on = validate_campaign_dates(body.starts_on, body.ends_on)
|
|
if await self.repo.get_by_code(tenant_id, body.program_id, code) is not None:
|
|
raise AppError(
|
|
"کد کمپین تکراری است",
|
|
status_code=409,
|
|
error_code="campaign_code_exists",
|
|
)
|
|
entity = Campaign(
|
|
tenant_id=tenant_id,
|
|
program_id=body.program_id,
|
|
code=code,
|
|
name=name,
|
|
description=body.description,
|
|
status=validate_campaign_status(body.status),
|
|
rule_version=1,
|
|
rules=body.rules,
|
|
starts_on=starts_on,
|
|
ends_on=ends_on,
|
|
created_by=actor_id,
|
|
updated_by=actor_id,
|
|
)
|
|
try:
|
|
await self.repo.add(entity)
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="campaign",
|
|
entity_id=entity.id,
|
|
action=AuditAction.CREATE,
|
|
actor_user_id=actor_id,
|
|
message=f"Campaign {entity.code} created",
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.CAMPAIGN_CREATED,
|
|
aggregate_type="campaign",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"code": entity.code, "rule_version": entity.rule_version},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
except IntegrityError:
|
|
await self.session.rollback()
|
|
raise _map_integrity(
|
|
error_code="campaign_code_exists",
|
|
message="کد کمپین تکراری است",
|
|
)
|
|
return entity
|
|
|
|
async def update(
|
|
self,
|
|
tenant_id: UUID,
|
|
campaign_id: UUID,
|
|
body: CampaignUpdate,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> Campaign:
|
|
entity = await self.get(tenant_id, campaign_id)
|
|
data = body.model_dump(exclude_unset=True, exclude={"bump_rule_version"})
|
|
_reject_nulls(data, {"name", "status"})
|
|
if "name" in data:
|
|
data["name"] = validate_non_empty(data["name"], field="name")
|
|
if "status" in data:
|
|
data["status"] = validate_campaign_status(data["status"])
|
|
starts = data.get("starts_on", entity.starts_on)
|
|
ends = data.get("ends_on", entity.ends_on)
|
|
if "starts_on" in data or "ends_on" in data:
|
|
starts, ends = validate_campaign_dates(starts, ends)
|
|
data["starts_on"] = starts
|
|
data["ends_on"] = ends
|
|
_apply_update(entity, data)
|
|
if body.bump_rule_version or ("rules" in data):
|
|
entity.rule_version += 1
|
|
entity.updated_by = actor.user_id if actor else None
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="campaign",
|
|
entity_id=entity.id,
|
|
action=AuditAction.UPDATE,
|
|
actor_user_id=entity.updated_by,
|
|
changes=_jsonable_changes({**data, "rule_version": entity.rule_version}),
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.CAMPAIGN_UPDATED,
|
|
aggregate_type="campaign",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"code": entity.code, "rule_version": entity.rule_version},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
return entity
|
|
|
|
async def get(self, tenant_id: UUID, campaign_id: UUID) -> Campaign:
|
|
entity = await self.repo.get(tenant_id, campaign_id)
|
|
if entity is None:
|
|
raise NotFoundError("کمپین یافت نشد", error_code="campaign_not_found")
|
|
return entity
|
|
|
|
async def list(self, tenant_id: UUID, *, offset: int, limit: int):
|
|
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)
|
|
|
|
async def soft_delete(
|
|
self,
|
|
tenant_id: UUID,
|
|
campaign_id: UUID,
|
|
*,
|
|
actor: CurrentUser | None = None,
|
|
) -> Campaign:
|
|
entity = await self.get(tenant_id, campaign_id)
|
|
entity.code = release_unique_token(entity.code, entity.id)
|
|
await self.repo.soft_delete(
|
|
entity, deleted_by=actor.user_id if actor else None
|
|
)
|
|
await self.audit.record(
|
|
tenant_id=tenant_id,
|
|
entity_type="campaign",
|
|
entity_id=entity.id,
|
|
action=AuditAction.DELETE,
|
|
actor_user_id=actor.user_id if actor else None,
|
|
)
|
|
await self.publisher.publish(
|
|
event_type=LoyaltyEventType.CAMPAIGN_DELETED,
|
|
aggregate_type="campaign",
|
|
aggregate_id=entity.id,
|
|
tenant_id=tenant_id,
|
|
payload={"code": entity.code},
|
|
)
|
|
await self.session.commit()
|
|
await self.session.refresh(entity)
|
|
return entity
|