TorbatYar/backend/services/loyalty/app/services/campaign_engine.py
Mortezakoohjani 071c484530 Ship Loyalty phases 7.2-7.6 (points through wallet) with production deploy.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 21:27:22 +03:30

312 lines
12 KiB
Python

"""Campaign Engine — lifecycle transitions, rule versioning, and point-granting apply (Phase 7.4)."""
from __future__ import annotations
from datetime import date, datetime, timezone
from uuid import UUID
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.campaigns import CampaignApplication
from app.models.foundation import Campaign
from app.models.types import AuditAction, CampaignApplicationStatus
from app.repositories.campaigns import CampaignApplicationRepository
from app.repositories.foundation import (
CampaignRepository,
MemberRepository,
PointAccountRepository,
)
from app.schemas.campaigns import ApplyCampaignRequest, CampaignRulesUpdateRequest
from app.schemas.ledger import PointEarnRequest
from app.services.audit_service import AuditService
from app.services.point_engine import PointEngineService
from app.validators.campaigns import (
compute_grant_amount,
ensure_campaign_active,
ensure_campaign_transition,
ensure_campaign_window,
ensure_max_applications_per_member,
ensure_positive_grant,
ensure_schedule_requires_start_date,
target_campaign_status,
)
from shared.exceptions import AppError, NotFoundError
from shared.security import CurrentUser
def _now() -> datetime:
return datetime.now(timezone.utc)
_ACTION_EVENTS: dict[str, LoyaltyEventType] = {
"schedule": LoyaltyEventType.CAMPAIGN_SCHEDULED,
"activate": LoyaltyEventType.CAMPAIGN_ACTIVATED,
"pause": LoyaltyEventType.CAMPAIGN_PAUSED,
"resume": LoyaltyEventType.CAMPAIGN_RESUMED,
"expire": LoyaltyEventType.CAMPAIGN_EXPIRED,
}
_ACTION_AUDIT: dict[str, AuditAction] = {
"schedule": AuditAction.SCHEDULE,
"activate": AuditAction.ACTIVATE,
"pause": AuditAction.PAUSE,
"resume": AuditAction.RESUME,
"expire": AuditAction.EXPIRE,
}
class CampaignEngineService:
def __init__(
self,
session: AsyncSession,
publisher: TransactionalEventPublisher | None = None,
) -> None:
self.session = session
self.campaigns = CampaignRepository(session)
self.applications = CampaignApplicationRepository(session)
self.members = MemberRepository(session)
self.accounts = PointAccountRepository(session)
self.audit = AuditService(session)
self.publisher = publisher or TransactionalEventPublisher(session)
self.point_engine = PointEngineService(session, publisher=self.publisher)
async def get(self, tenant_id: UUID, campaign_id: UUID) -> Campaign:
entity = await self.campaigns.get(tenant_id, campaign_id)
if entity is None:
raise NotFoundError("کمپین یافت نشد", error_code="campaign_not_found")
return entity
async def _transition(
self,
tenant_id: UUID,
campaign_id: UUID,
action: str,
*,
actor: CurrentUser | None = None,
) -> Campaign:
entity = await self.get(tenant_id, campaign_id)
ensure_campaign_transition(action=action, current=entity.status)
if action == "schedule":
ensure_schedule_requires_start_date(entity.starts_on)
from_status = entity.status
to_status = target_campaign_status(action)
actor_id = actor.user_id if actor else None
entity.status = to_status
entity.updated_by = actor_id
await self.audit.record(
tenant_id=tenant_id,
entity_type="campaign",
entity_id=entity.id,
action=_ACTION_AUDIT[action],
actor_user_id=actor_id,
message=(
f"Campaign {entity.code} {action}: "
f"{from_status.value} -> {to_status.value}"
),
changes={"from_status": from_status.value, "to_status": to_status.value},
)
await self.publisher.publish(
event_type=_ACTION_EVENTS[action],
aggregate_type="campaign",
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 schedule(
self, tenant_id: UUID, campaign_id: UUID, *, actor: CurrentUser | None = None
) -> Campaign:
return await self._transition(tenant_id, campaign_id, "schedule", actor=actor)
async def activate(
self, tenant_id: UUID, campaign_id: UUID, *, actor: CurrentUser | None = None
) -> Campaign:
return await self._transition(tenant_id, campaign_id, "activate", actor=actor)
async def pause(
self, tenant_id: UUID, campaign_id: UUID, *, actor: CurrentUser | None = None
) -> Campaign:
return await self._transition(tenant_id, campaign_id, "pause", actor=actor)
async def resume(
self, tenant_id: UUID, campaign_id: UUID, *, actor: CurrentUser | None = None
) -> Campaign:
return await self._transition(tenant_id, campaign_id, "resume", actor=actor)
async def expire(
self, tenant_id: UUID, campaign_id: UUID, *, actor: CurrentUser | None = None
) -> Campaign:
return await self._transition(tenant_id, campaign_id, "expire", actor=actor)
async def update_rules(
self,
tenant_id: UUID,
campaign_id: UUID,
body: CampaignRulesUpdateRequest,
*,
actor: CurrentUser | None = None,
) -> Campaign:
entity = await self.get(tenant_id, campaign_id)
actor_id = actor.user_id if actor else None
entity.rules = body.rules
entity.rule_version += 1
entity.updated_by = actor_id
await self.audit.record(
tenant_id=tenant_id,
entity_type="campaign",
entity_id=entity.id,
action=AuditAction.RULES_UPDATE,
actor_user_id=actor_id,
message=f"Campaign {entity.code} rules updated to v{entity.rule_version}",
changes={"rules": body.rules, "rule_version": entity.rule_version},
)
await self.publisher.publish(
event_type=LoyaltyEventType.CAMPAIGN_RULES_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 apply(
self,
tenant_id: UUID,
campaign_id: UUID,
body: ApplyCampaignRequest,
*,
actor: CurrentUser | None = None,
) -> CampaignApplication:
if body.idempotency_key:
existing = await self.applications.get_by_idempotency(
tenant_id, body.idempotency_key
)
if existing is not None:
return existing
campaign = await self.get(tenant_id, campaign_id)
ensure_campaign_active(campaign.status)
ensure_campaign_window(campaign.starts_on, campaign.ends_on, today=date.today())
member = await self.members.get(tenant_id, body.member_id)
if member is None:
raise NotFoundError("عضو یافت نشد", error_code="member_not_found")
if member.program_id != campaign.program_id:
raise AppError(
"عضو متعلق به برنامه این کمپین نیست",
status_code=409,
error_code="member_program_mismatch",
)
if body.point_account_id is not None:
account = await self.accounts.get(tenant_id, body.point_account_id)
if account is None or account.member_id != member.id:
raise NotFoundError(
"حساب امتیاز یافت نشد", error_code="point_account_not_found"
)
else:
account = await self.accounts.get_by_member(tenant_id, member.id)
if account is None:
raise NotFoundError(
"حساب امتیاز یافت نشد", error_code="point_account_not_found"
)
rules = campaign.rules or {}
max_applications = rules.get("max_applications_per_member")
existing_count = await self.applications.count_for_member(
tenant_id,
campaign.id,
member.id,
exclude_statuses=(CampaignApplicationStatus.REVERSED,),
)
ensure_max_applications_per_member(existing_count, max_applications)
grant = ensure_positive_grant(compute_grant_amount(rules, body.base_amount))
actor_id = actor.user_id if actor else None
entry = await self.point_engine.earn(
tenant_id,
account.id,
PointEarnRequest(
amount=grant,
reason=f"campaign:{campaign.code}",
reference_type="campaign_application",
reference_id=str(campaign.id),
),
actor=actor,
auto_commit=False,
)
application = CampaignApplication(
tenant_id=tenant_id,
campaign_id=campaign.id,
program_id=campaign.program_id,
member_id=member.id,
point_account_id=account.id,
rule_version=campaign.rule_version,
points_granted=grant,
ledger_entry_id=entry.id,
status=CampaignApplicationStatus.APPLIED,
idempotency_key=body.idempotency_key,
actor_user_id=actor_id,
metadata_json=body.metadata_json,
)
try:
await self.applications.add(application)
except IntegrityError:
await self.session.rollback()
raise AppError(
"اعمال کمپین تکراری است",
status_code=409,
error_code="campaign_application_idempotency_conflict",
)
await self.audit.record(
tenant_id=tenant_id,
entity_type="campaign_application",
entity_id=application.id,
action=AuditAction.APPLY,
actor_user_id=actor_id,
message=(
f"Campaign {campaign.code} applied to member "
f"{member.membership_number} (+{grant})"
),
changes={"points_granted": grant, "campaign_id": str(campaign.id)},
)
await self.publisher.publish(
event_type=LoyaltyEventType.CAMPAIGN_APPLIED,
aggregate_type="campaign_application",
aggregate_id=application.id,
tenant_id=tenant_id,
payload={
"campaign_id": str(campaign.id),
"member_id": str(member.id),
"points_granted": grant,
},
)
await self.session.commit()
await self.session.refresh(application)
return application
async def list_applications(
self,
tenant_id: UUID,
campaign_id: UUID,
*,
offset: int = 0,
limit: int = 50,
):
await self.get(tenant_id, campaign_id)
rows = await self.applications.list_for_campaign(
tenant_id, campaign_id, offset=offset, limit=limit
)
total = await self.applications.count_for_campaign(tenant_id, campaign_id)
return rows, total