"""Loyalty foundation aggregates — Phase 7.0. Independent aggregates use UUID references within loyalty_db only. No SQLAlchemy relationship graphs between aggregates. Balances are never stored as mutable fields — ledger arrives in Phase 7.2. """ from __future__ import annotations import uuid from datetime import date, datetime from sqlalchemy import ( Boolean, Date, DateTime, Index, Integer, String, Text, UniqueConstraint, text, ) from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.types import JSON from app.core.database import Base from app.models.base import ( ActorAuditMixin, OptimisticLockMixin, SoftDeleteMixin, TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin, ) from app.models.types import ( AuditAction, CampaignStatus, GUID, MemberStatus, MembershipLifecycleAction, PointAccountStatus, ProgramStatus, RewardStatus, RewardType, TierStatus, ) class LoyaltyProgram( Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, OptimisticLockMixin, ): """Tenant loyalty program configuration aggregate.""" __tablename__ = "loyalty_programs" code: Mapped[str] = mapped_column(String(50), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) status: Mapped[ProgramStatus] = mapped_column( default=ProgramStatus.DRAFT, nullable=False ) points_currency_name: Mapped[str] = mapped_column( String(50), default="points", nullable=False ) currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False) is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) settings: Mapped[dict | None] = mapped_column(JSON, nullable=True) __table_args__ = ( UniqueConstraint( "tenant_id", "code", name="uq_loyalty_programs_tenant_code" ), Index("ix_loyalty_programs_tenant_status", "tenant_id", "status"), Index("ix_loyalty_programs_tenant_deleted", "tenant_id", "is_deleted"), Index( "uq_loyalty_programs_tenant_default_live", "tenant_id", unique=True, sqlite_where=text("is_default = 1 AND is_deleted = 0"), postgresql_where=text("is_default IS TRUE AND is_deleted IS FALSE"), ), ) class MembershipTier( Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, ): """Membership tier / level definition aggregate (Phase 7.1 eligibility uses rank/min_points).""" __tablename__ = "membership_tiers" program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) code: Mapped[str] = mapped_column(String(50), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) rank: Mapped[int] = mapped_column(Integer, default=0, nullable=False) min_points: Mapped[int] = mapped_column(Integer, default=0, nullable=False) status: Mapped[TierStatus] = mapped_column(default=TierStatus.ACTIVE, nullable=False) benefits: Mapped[dict | None] = mapped_column(JSON, nullable=True) rules: Mapped[dict | None] = mapped_column(JSON, nullable=True) __table_args__ = ( UniqueConstraint( "tenant_id", "program_id", "code", name="uq_membership_tiers_tenant_code" ), Index("ix_membership_tiers_program", "tenant_id", "program_id"), Index("ix_membership_tiers_tenant_deleted", "tenant_id", "is_deleted"), ) class Member( Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, OptimisticLockMixin, ): """Loyalty member aggregate with Phase 7.1 lifecycle fields.""" __tablename__ = "members" program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) membership_number: Mapped[str] = mapped_column(String(50), nullable=False) external_customer_ref: Mapped[str | None] = mapped_column(String(100), nullable=True) display_name: Mapped[str] = mapped_column(String(255), nullable=False) email: Mapped[str | None] = mapped_column(String(255), nullable=True) mobile: Mapped[str | None] = mapped_column(String(50), nullable=True) status: Mapped[MemberStatus] = mapped_column( default=MemberStatus.PENDING, nullable=False ) tier_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) enrolled_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) activated_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) membership_started_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) membership_expires_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) frozen_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) freeze_reason: Mapped[str | None] = mapped_column(String(500), nullable=True) cancelled_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) cancel_reason: Mapped[str | None] = mapped_column(String(500), nullable=True) expired_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) transferred_to_member_id: Mapped[uuid.UUID | None] = mapped_column( GUID(), nullable=True ) profile: Mapped[dict | None] = mapped_column(JSON, nullable=True) __table_args__ = ( UniqueConstraint( "tenant_id", "membership_number", name="uq_members_tenant_membership_number", ), UniqueConstraint( "tenant_id", "program_id", "external_customer_ref", name="uq_members_tenant_program_external_ref", ), Index("ix_members_tenant_program", "tenant_id", "program_id"), Index("ix_members_tenant_status", "tenant_id", "status"), Index("ix_members_tenant_email", "tenant_id", "email"), Index("ix_members_tenant_tier", "tenant_id", "tier_id"), Index("ix_members_tenant_expires", "tenant_id", "membership_expires_at"), Index("ix_members_tenant_deleted", "tenant_id", "is_deleted"), ) class MembershipLifecycleEvent( Base, UUIDPrimaryKeyMixin, TenantMixin, ): """Append-only membership lifecycle history (Phase 7.1).""" __tablename__ = "membership_lifecycle_events" member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) action: Mapped[MembershipLifecycleAction] = mapped_column(nullable=False) from_status: Mapped[str] = mapped_column(String(30), nullable=False) to_status: Mapped[str] = mapped_column(String(30), nullable=False) reason: Mapped[str | None] = mapped_column(String(500), nullable=True) metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False ) __table_args__ = ( Index("ix_membership_lifecycle_member", "tenant_id", "member_id"), Index("ix_membership_lifecycle_action", "tenant_id", "action"), Index("ix_membership_lifecycle_created", "tenant_id", "created_at"), ) class PointAccount( Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, OptimisticLockMixin, ): """Point account shell — balance only via immutable ledger (Phase 7.2).""" __tablename__ = "point_accounts" program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) account_number: Mapped[str] = mapped_column(String(50), nullable=False) status: Mapped[PointAccountStatus] = mapped_column( default=PointAccountStatus.OPEN, nullable=False ) currency_name: Mapped[str] = mapped_column( String(50), default="points", nullable=False ) __table_args__ = ( UniqueConstraint( "tenant_id", "account_number", name="uq_point_accounts_tenant_account_number", ), UniqueConstraint( "tenant_id", "member_id", name="uq_point_accounts_tenant_member", ), Index("ix_point_accounts_program", "tenant_id", "program_id"), Index("ix_point_accounts_tenant_deleted", "tenant_id", "is_deleted"), ) class Reward( Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, ): """Reward catalog shell (expanded in Phase 7.3).""" __tablename__ = "rewards" program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) code: Mapped[str] = mapped_column(String(50), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) reward_type: Mapped[RewardType] = mapped_column( default=RewardType.CUSTOM, nullable=False ) status: Mapped[RewardStatus] = mapped_column( default=RewardStatus.DRAFT, nullable=False ) points_cost: Mapped[int] = mapped_column(Integer, default=0, nullable=False) eligibility_rules: Mapped[dict | None] = mapped_column(JSON, nullable=True) metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) __table_args__ = ( UniqueConstraint("tenant_id", "program_id", "code", name="uq_rewards_tenant_code"), Index("ix_rewards_tenant_status", "tenant_id", "status"), Index("ix_rewards_tenant_deleted", "tenant_id", "is_deleted"), ) class Campaign( Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, ): """Campaign shell (expanded in Phase 7.4). Rules are versioned later.""" __tablename__ = "campaigns" program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) code: Mapped[str] = mapped_column(String(50), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) status: Mapped[CampaignStatus] = mapped_column( default=CampaignStatus.DRAFT, nullable=False ) rule_version: Mapped[int] = mapped_column(Integer, default=1, nullable=False) rules: Mapped[dict | None] = mapped_column(JSON, nullable=True) starts_on: Mapped[date | None] = mapped_column(Date, nullable=True) ends_on: Mapped[date | None] = mapped_column(Date, nullable=True) __table_args__ = ( UniqueConstraint( "tenant_id", "program_id", "code", name="uq_campaigns_tenant_code" ), Index("ix_campaigns_tenant_status", "tenant_id", "status"), Index("ix_campaigns_tenant_deleted", "tenant_id", "is_deleted"), ) class LoyaltyAuditLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): """Append-only loyalty audit trail.""" __tablename__ = "loyalty_audit_logs" entity_type: Mapped[str] = mapped_column(String(50), nullable=False) entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) action: Mapped[AuditAction] = mapped_column(nullable=False) actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True) changes: Mapped[dict | None] = mapped_column(JSON, nullable=True) message: Mapped[str | None] = mapped_column(Text, nullable=True) __table_args__ = ( Index( "ix_loyalty_audit_entity", "tenant_id", "entity_type", "entity_id", ), )