diff --git a/.env.example b/.env.example index 37c3610..ca17bbb 100644 --- a/.env.example +++ b/.env.example @@ -1,143 +1,172 @@ -# ============================================================ -# SuperApp SaaS Platform - Environment Configuration Example -# این فایل را به .env کپی کنید و مقادیر واقعی را جایگزین نمایید. -# ============================================================ - -# ---- General ---- -ENVIRONMENT=development # development | staging | production -DEBUG=true -LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR -SERVICE_NAME=core-service -API_V1_PREFIX=/api/v1 - -# ---- Platform Branding (White-label) ---- -# برند نباید در کد hardcode شود؛ از این مقادیر استفاده کنید. -PLATFORM_NAME=Torbatyar -PLATFORM_SUPPORT_EMAIL=support@example.com -PLATFORM_PRIMARY_COLOR=#0284c7 -PLATFORM_SECONDARY_COLOR=#0f172a -# دامنه پایه برای تخصیص خودکار زیردامنه tenant در onboarding (مثلاً .torbatyar.xyz) -# دامنه پایه برای تخصیص خودکار زیردامنه tenant (مثلاً .torbatyar.ir) -PLATFORM_BASE_DOMAIN=torbatyar.ir - -# ---- Auto SSL for tenant domains (Celery → SSH → nginx certbot) ---- -SSL_PROVISION_ENABLED=false -SSL_PROVISION_HOST=192.168.10.156 -SSL_PROVISION_USER=torbatyaruser -SSL_PROVISION_PASSWORD= -SSL_PROVISION_SCRIPT=/opt/torbatyar/bin/provision_ssl.py - -# ---- Core Database (core_platform_db) ---- -POSTGRES_HOST=postgres -POSTGRES_PORT=5432 -POSTGRES_USER=superapp -POSTGRES_PASSWORD=superapp_password -POSTGRES_DB=core_platform_db -# async driver برای FastAPI/SQLAlchemy -DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/core_platform_db -# sync driver برای Alembic -DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/core_platform_db - -# ---- Redis ---- -REDIS_HOST=redis -REDIS_PORT=6379 -REDIS_DB=0 -REDIS_URL=redis://redis:6379/0 -FEATURE_ACCESS_CACHE_TTL=300 # ثانیه -TENANT_METADATA_CACHE_TTL=300 - -# ---- Celery ---- -CELERY_BROKER_URL=redis://redis:6379/1 -CELERY_RESULT_BACKEND=redis://redis:6379/2 - -# ---- Keycloak / SSO (فاز ۲ — فعال در Docker) ---- -# Production: auth.torbatyar.ir پشت nginx روی 192.168.10.156 -# Dev محلی: می‌توانید hosts را به 127.0.0.1 اشاره دهید. -KEYCLOAK_ENABLED=true -KEYCLOAK_SERVER_URL=http://keycloak:8080 -KEYCLOAK_PUBLIC_URL=https://auth.torbatyar.ir -KEYCLOAK_HOSTNAME=auth.torbatyar.ir -KEYCLOAK_HOSTNAME_STRICT_HTTPS=false -KEYCLOAK_REALM=superapp -KEYCLOAK_CLIENT_ID=core-service -KEYCLOAK_CLIENT_SECRET=change-me -JWT_ALGORITHM=RS256 -JWT_AUDIENCE=account -JWT_VERIFY_SIGNATURE=true -# در production و Docker باید true باشد؛ در تست‌ها false -AUTH_REQUIRED=true - -# ---- Identity & Access Service ---- -IDENTITY_SERVICE_URL=http://identity-access-service:8001 -IDENTITY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/identity_access_db -IDENTITY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/identity_access_db -KEYCLOAK_FRONTEND_CLIENT_ID=superapp-frontend -KEYCLOAK_IDENTITY_CLIENT_SECRET=change-me-identity -IDENTITY_KEYCLOAK_CLIENT_ID=identity-access-service -FRONTEND_CALLBACK_URL=https://torbatyar.ir/auth/callback -CORS_ORIGINS=https://torbatyar.ir,https://www.torbatyar.ir,http://torbatyar.ir,http://www.torbatyar.ir,http://localhost:3000 -CORE_SERVICE_URL=http://core-service:8000 - -# ---- Accounting Service ---- -ACCOUNTING_SERVICE_URL=http://accounting-service:8002 -ACCOUNTING_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/accounting_db -ACCOUNTING_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/accounting_db -ACCOUNTING_SERVICE_NAME=accounting-service - -# ---- CRM Service ---- -CRM_SERVICE_URL=http://crm-service:8003 -CRM_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/crm_db -CRM_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/crm_db -CRM_SERVICE_NAME=crm-service - -# ---- Loyalty Service ---- -LOYALTY_SERVICE_URL=http://loyalty-service:8004 -LOYALTY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/loyalty_db -LOYALTY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/loyalty_db -LOYALTY_SERVICE_NAME=loyalty-service - -# ---- Communication Service (Phase 8) ---- -COMMUNICATION_SERVICE_URL=http://communication-service:8005 -COMMUNICATION_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/communication_db -COMMUNICATION_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/communication_db -COMMUNICATION_SERVICE_NAME=communication-service - -# ---- Sports Center Service (Phase 9.0) ---- -SPORTS_CENTER_SERVICE_URL=http://sports-center-service:8006 -SPORTS_CENTER_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/sports_center_db -SPORTS_CENTER_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/sports_center_db -SPORTS_CENTER_SERVICE_NAME=sports-center-service - -# ---- OTP / Payamak SMS (Core Service) ---- -# ApiKey را در پارامتر password ارسال کنید (طبق مستندات ملی‌پیامک) -PAYAMAK_USERNAME=9155105404 -PAYAMAK_FROM=9982004961 -PAYAMAK_FROM_NUMBER=9982004961 -# ApiKey را در پارامتر password ارسال کنید (مستندات ملی‌پیامک) -PAYAMAK_API_KEY=967ceb37-eeef-429c-b6c5-b60a15306e4f -PAYAMAK_APIKEY=967ceb37-eeef-429c-b6c5-b60a15306e4f -PAYAMAK_PASSWORD=967ceb37-eeef-429c-b6c5-b60a15306e4f -PAYAMAK_BODY_ID=245189 -# آدرس رسمی: https://api.payamak-panel.com/post/send.asmx/SendByBaseNumber -JWT_SECRET=change-me-jwt-secret -OTP_EXPIRE_SECONDS=120 - -# شماره‌های ادمین پلتفرم (OTP) — با کاما جدا شوند -PLATFORM_ADMIN_MOBILES=09155105404 - -# ---- Frontend (Next.js) ---- -FRONTEND_PORT=3000 -NEXT_PUBLIC_PLATFORM_BASE_DOMAIN=torbatyar.ir -NEXT_PUBLIC_BACKEND_URL=https://api.torbatyar.ir -NEXT_PUBLIC_API_BASE_URL=https://api.torbatyar.ir -NEXT_PUBLIC_IDENTITY_API_URL=https://identity.torbatyar.ir -NEXT_PUBLIC_KEYCLOAK_URL=https://auth.torbatyar.ir -NEXT_PUBLIC_KEYCLOAK_REALM=superapp -NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend -NEXT_PUBLIC_ACCOUNTING_API_URL=http://localhost:8002 - -INTERNAL_TOKEN_SECRET=change-me-internal-secret - -# ---- Keycloak Bootstrap ---- -KEYCLOAK_ADMIN=admin -KEYCLOAK_ADMIN_PASSWORD=admin +# ============================================================ +# SuperApp SaaS Platform - Environment Configuration Example +# این فایل را به .env کپی کنید و مقادیر واقعی را جایگزین نمایید. +# ============================================================ + +# ---- General ---- +ENVIRONMENT=development # development | staging | production +DEBUG=true +LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR +SERVICE_NAME=core-service +API_V1_PREFIX=/api/v1 + +# ---- Platform Branding (White-label) ---- +# برند نباید در کد hardcode شود؛ از این مقادیر استفاده کنید. +PLATFORM_NAME=Torbatyar +PLATFORM_SUPPORT_EMAIL=support@example.com +PLATFORM_PRIMARY_COLOR=#0284c7 +PLATFORM_SECONDARY_COLOR=#0f172a +# دامنه پایه برای تخصیص خودکار زیردامنه tenant در onboarding (مثلاً .torbatyar.xyz) +# دامنه پایه برای تخصیص خودکار زیردامنه tenant (مثلاً .torbatyar.ir) +PLATFORM_BASE_DOMAIN=torbatyar.ir + +# ---- Auto SSL for tenant domains (Celery → SSH → nginx certbot) ---- +SSL_PROVISION_ENABLED=false +SSL_PROVISION_HOST=192.168.10.156 +SSL_PROVISION_USER=torbatyaruser +SSL_PROVISION_PASSWORD= +SSL_PROVISION_SCRIPT=/opt/torbatyar/bin/provision_ssl.py + +# ---- Core Database (core_platform_db) ---- +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_USER=superapp +POSTGRES_PASSWORD=superapp_password +POSTGRES_DB=core_platform_db +# async driver برای FastAPI/SQLAlchemy +DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/core_platform_db +# sync driver برای Alembic +DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/core_platform_db + +# ---- Redis ---- +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_DB=0 +REDIS_URL=redis://redis:6379/0 +FEATURE_ACCESS_CACHE_TTL=300 # ثانیه +TENANT_METADATA_CACHE_TTL=300 + +# ---- Celery ---- +CELERY_BROKER_URL=redis://redis:6379/1 +CELERY_RESULT_BACKEND=redis://redis:6379/2 + +# ---- Keycloak / SSO (فاز ۲ — فعال در Docker) ---- +# Production: auth.torbatyar.ir پشت nginx روی 192.168.10.156 +# Dev محلی: می‌توانید hosts را به 127.0.0.1 اشاره دهید. +KEYCLOAK_ENABLED=true +KEYCLOAK_SERVER_URL=http://keycloak:8080 +KEYCLOAK_PUBLIC_URL=https://auth.torbatyar.ir +KEYCLOAK_HOSTNAME=auth.torbatyar.ir +KEYCLOAK_HOSTNAME_STRICT_HTTPS=false +KEYCLOAK_REALM=superapp +KEYCLOAK_CLIENT_ID=core-service +KEYCLOAK_CLIENT_SECRET=change-me +JWT_ALGORITHM=RS256 +JWT_AUDIENCE=account +JWT_VERIFY_SIGNATURE=true +# در production و Docker باید true باشد؛ در تست‌ها false +AUTH_REQUIRED=true + +# ---- Identity & Access Service ---- +IDENTITY_SERVICE_URL=http://identity-access-service:8001 +IDENTITY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/identity_access_db +IDENTITY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/identity_access_db +KEYCLOAK_FRONTEND_CLIENT_ID=superapp-frontend +KEYCLOAK_IDENTITY_CLIENT_SECRET=change-me-identity +IDENTITY_KEYCLOAK_CLIENT_ID=identity-access-service +FRONTEND_CALLBACK_URL=https://torbatyar.ir/auth/callback +CORS_ORIGINS=https://torbatyar.ir,https://www.torbatyar.ir,http://torbatyar.ir,http://www.torbatyar.ir,http://localhost:3000 +CORE_SERVICE_URL=http://core-service:8000 + +# ---- Accounting Service ---- +ACCOUNTING_SERVICE_URL=http://accounting-service:8002 +ACCOUNTING_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/accounting_db +ACCOUNTING_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/accounting_db +ACCOUNTING_SERVICE_NAME=accounting-service + +# ---- CRM Service ---- +CRM_SERVICE_URL=http://crm-service:8003 +CRM_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/crm_db +CRM_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/crm_db +CRM_SERVICE_NAME=crm-service + +# ---- Loyalty Service ---- +LOYALTY_SERVICE_URL=http://loyalty-service:8004 +LOYALTY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/loyalty_db +LOYALTY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/loyalty_db +LOYALTY_SERVICE_NAME=loyalty-service + +# ---- Communication Service (Phase 8) ---- +COMMUNICATION_SERVICE_URL=http://communication-service:8005 +COMMUNICATION_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/communication_db +COMMUNICATION_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/communication_db +COMMUNICATION_SERVICE_NAME=communication-service + +# ---- Sports Center Service (Phase 9.0) ---- +SPORTS_CENTER_SERVICE_URL=http://sports-center-service:8006 +SPORTS_CENTER_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/sports_center_db +SPORTS_CENTER_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/sports_center_db +SPORTS_CENTER_SERVICE_NAME=sports-center-service + +# ---- Delivery Service (Phase 10.0 / Torbat Driver) ---- +DELIVERY_SERVICE_URL=http://delivery-service:8007 +DELIVERY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/delivery_db +DELIVERY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/delivery_db +DELIVERY_SERVICE_NAME=delivery-service + +# ---- Healthcare Service (Phase 13.0) ---- +HEALTHCARE_SERVICE_URL=http://healthcare-service:8010 +HEALTHCARE_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/healthcare_db +HEALTHCARE_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/healthcare_db +HEALTHCARE_SERVICE_NAME=healthcare-service + +# ---- Beauty Business Service (Phase 14.0 / Torbat Beauty) ---- +BEAUTY_BUSINESS_SERVICE_URL=http://beauty-business-service:8011 +BEAUTY_BUSINESS_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/beauty_business_db +BEAUTY_BUSINESS_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/beauty_business_db +BEAUTY_BUSINESS_SERVICE_NAME=beauty-business-service + +# ---- Hospitality Service (Phase 12.0 / Torbat Food) ---- +HOSPITALITY_SERVICE_URL=http://hospitality-service:8009 +HOSPITALITY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/hospitality_db +HOSPITALITY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/hospitality_db +HOSPITALITY_SERVICE_NAME=hospitality-service + +EXPERIENCE_SERVICE_URL=http://experience-service:8008 +EXPERIENCE_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/experience_db +EXPERIENCE_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/experience_db +EXPERIENCE_SERVICE_NAME=experience-service + +# ---- OTP / Payamak SMS (Core Service) ---- +# ApiKey را در پارامتر password ارسال کنید (طبق مستندات ملی‌پیامک) +PAYAMAK_USERNAME=9155105404 +PAYAMAK_FROM=9982004961 +PAYAMAK_FROM_NUMBER=9982004961 +# ApiKey را در پارامتر password ارسال کنید (مستندات ملی‌پیامک) +PAYAMAK_API_KEY=967ceb37-eeef-429c-b6c5-b60a15306e4f +PAYAMAK_APIKEY=967ceb37-eeef-429c-b6c5-b60a15306e4f +PAYAMAK_PASSWORD=967ceb37-eeef-429c-b6c5-b60a15306e4f +PAYAMAK_BODY_ID=245189 +# آدرس رسمی: https://api.payamak-panel.com/post/send.asmx/SendByBaseNumber +JWT_SECRET=change-me-jwt-secret +OTP_EXPIRE_SECONDS=120 + +# شماره‌های ادمین پلتفرم (OTP) — با کاما جدا شوند +PLATFORM_ADMIN_MOBILES=09155105404 + +# ---- Frontend (Next.js) ---- +FRONTEND_PORT=3000 +NEXT_PUBLIC_PLATFORM_BASE_DOMAIN=torbatyar.ir +NEXT_PUBLIC_BACKEND_URL=https://api.torbatyar.ir +NEXT_PUBLIC_API_BASE_URL=https://api.torbatyar.ir +NEXT_PUBLIC_IDENTITY_API_URL=https://identity.torbatyar.ir +NEXT_PUBLIC_KEYCLOAK_URL=https://auth.torbatyar.ir +NEXT_PUBLIC_KEYCLOAK_REALM=superapp +NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend +NEXT_PUBLIC_ACCOUNTING_API_URL=http://localhost:8002 + +INTERNAL_TOKEN_SECRET=change-me-internal-secret + +# ---- Keycloak Bootstrap ---- +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=admin diff --git a/backend/services/loyalty/README.md b/backend/services/loyalty/README.md index 7d90719..0887d69 100644 --- a/backend/services/loyalty/README.md +++ b/backend/services/loyalty/README.md @@ -1,6 +1,6 @@ # Loyalty Service — Enterprise Loyalty Platform -> Phase 7.1 Membership Engine complete (foundation 7.0 + lifecycle). Independent shared service — **not** part of CRM. +> Phase 7.6 Wallet complete (7.0–7.5 engines + immutable wallet ledger). Independent shared service — **not** part of CRM. Reusable by Restaurant, Marketplace, Ecommerce, Academy, Booking, Healthcare, Salon, Gym, and future modules via API + Events only. @@ -11,40 +11,23 @@ Salon, Gym, and future modules via API + Events only. | --- | --- | | LoyaltyProgram, MembershipTier, Member + lifecycle | CRM sales entities | | MembershipLifecycleEvent history | Accounting / Posting Engine | -| PointAccount shell (no mutable balance) | Notification delivery | -| Reward catalog shell | Analytics store (later) | -| Campaign shell (versioned rules later) | Identity / Wallet UI / Gift card engine (later phases) | +| PointAccount + immutable `PointLedgerEntry` | Notification delivery | +| Reward catalog + `RewardRedemption` lifecycle | Analytics store (later) | +| Campaign engine + applications | Identity / Gift card UI (7.7+) | +| Referral programs / codes / attributions | Payment gateway settlement | +| WalletAccount + immutable `WalletLedgerEntry` | | | Loyalty audit trail | | ## Boundaries - Independent database: `loyalty_db` (ADR-001, ADR-011) - Row-level tenancy via `tenant_id` from request context (ADR-003) -- Membership lifecycle transitions validated in `MembershipEngineService` -- Program soft-delete rejected while blocking members exist -- Direct balance modification is prohibited — ledger in Phase 7.2 +- Point and wallet balances come **only** from immutable ledgers — never mutate a balance column - Soft delete releases unique keys so codes can be reused; audit via `/api/v1/audit` - Inter-service communication: REST + Events only (transactional outbox) - Platform providers are **contracts only** under `app/providers/` - Route permissions enforced (`loyalty.*`); invalid `X-Tenant-ID` rejected -## Structure - -``` -app/ - api/v1/ # Loyalty HTTP APIs only - models/ # foundation aggregates - repositories/ - services/ - validators/ - schemas/ - events/ - permissions/ - providers/ - tests/ -alembic/versions/0001_initial.py -``` - ## API Prefix `/api/v1` on port **8004** @@ -54,15 +37,16 @@ alembic/versions/0001_initial.py | Programs | `/api/v1/programs` | | Tiers | `/api/v1/tiers` | | Members | `/api/v1/members` | -| Point accounts | `/api/v1/point-accounts` | -| Rewards | `/api/v1/rewards` | +| Point accounts | `/api/v1/point-accounts` (+ ledger ops) | +| Rewards / Redemptions | `/api/v1/rewards`, `/api/v1/redemptions` | | Campaigns | `/api/v1/campaigns` | +| Referrals | `/api/v1/referral-programs`, `/referral-codes`, `/referrals` | +| Wallets | `/api/v1/wallets` (+ `/balance`, `/ledger`, `/credit`, `/debit`, `/adjust`, `/transfer`) | | Health | `/health` | ## Permissions -`loyalty.*` including `loyalty.programs.*`, `loyalty.tiers.*`, `loyalty.members.*`, -`loyalty.point_accounts.*`, `loyalty.rewards.*`, `loyalty.campaigns.*`, `loyalty.audit.*` +`loyalty.*` including points, rewards, campaigns, referrals, wallets, audit trees. ## Run locally @@ -75,7 +59,8 @@ uvicorn app.main:app --port 8004 --reload ## Related Documents -- [Phase 7.0](../../../docs/loyalty-phase-7-0.md) +- [Phase 7.6](../../../docs/loyalty-phase-7-6.md) +- [Handover 7.6](../../../docs/phase-handover/phase-7-6.md) +- [Service Snapshot](../../../docs/service-snapshots/loyalty.yaml) - [Module Registry](../../../docs/module-registry.md#loyalty) - [ADR-011](../../../docs/architecture/adr/ADR-011.md) -- [Architecture](../../../docs/architecture/architecture.md) diff --git a/backend/services/loyalty/alembic/versions/0003_phase_72_points.py b/backend/services/loyalty/alembic/versions/0003_phase_72_points.py new file mode 100644 index 0000000..488bd3a --- /dev/null +++ b/backend/services/loyalty/alembic/versions/0003_phase_72_points.py @@ -0,0 +1,61 @@ +"""Phase 7.2 — immutable point ledger.""" +from alembic import op +import sqlalchemy as sa + +revision = "0003_phase_72_points" +down_revision = "0002_phase_71_membership" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "point_ledger_entries", + sa.Column("id", sa.CHAR(length=36), primary_key=True), + sa.Column("tenant_id", sa.CHAR(length=36), nullable=False), + sa.Column("point_account_id", sa.CHAR(length=36), nullable=False), + sa.Column("program_id", sa.CHAR(length=36), nullable=False), + sa.Column("member_id", sa.CHAR(length=36), nullable=False), + sa.Column("entry_type", sa.String(length=30), nullable=False), + sa.Column("amount", sa.Integer(), nullable=False), + sa.Column("balance_after", sa.Integer(), nullable=False), + sa.Column("idempotency_key", sa.String(length=100), nullable=True), + sa.Column("reference_type", sa.String(length=50), nullable=True), + sa.Column("reference_id", sa.String(length=100), nullable=True), + sa.Column("reason", sa.String(length=500), nullable=True), + sa.Column("metadata_json", sa.JSON(), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("actor_user_id", sa.String(length=100), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_point_ledger_tenant_idempotency", + ), + ) + op.create_index( + "ix_point_ledger_account", + "point_ledger_entries", + ["tenant_id", "point_account_id"], + ) + op.create_index( + "ix_point_ledger_member", "point_ledger_entries", ["tenant_id", "member_id"] + ) + op.create_index( + "ix_point_ledger_type", "point_ledger_entries", ["tenant_id", "entry_type"] + ) + op.create_index( + "ix_point_ledger_created", "point_ledger_entries", ["tenant_id", "created_at"] + ) + op.create_index( + "ix_point_ledger_expires", "point_ledger_entries", ["tenant_id", "expires_at"] + ) + + +def downgrade(): + op.drop_index("ix_point_ledger_expires", table_name="point_ledger_entries") + op.drop_index("ix_point_ledger_created", table_name="point_ledger_entries") + op.drop_index("ix_point_ledger_type", table_name="point_ledger_entries") + op.drop_index("ix_point_ledger_member", table_name="point_ledger_entries") + op.drop_index("ix_point_ledger_account", table_name="point_ledger_entries") + op.drop_table("point_ledger_entries") diff --git a/backend/services/loyalty/alembic/versions/0004_phase_73_rewards.py b/backend/services/loyalty/alembic/versions/0004_phase_73_rewards.py new file mode 100644 index 0000000..3efa918 --- /dev/null +++ b/backend/services/loyalty/alembic/versions/0004_phase_73_rewards.py @@ -0,0 +1,72 @@ +"""Phase 7.3 — Rewards Engine (reward redemptions).""" +from alembic import op +import sqlalchemy as sa + +revision = "0004_phase_73_rewards" +down_revision = "0003_phase_72_points" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("rewards", sa.Column("stock_limit", sa.Integer(), nullable=True)) + op.add_column( + "rewards", + sa.Column( + "redeemed_count", sa.Integer(), nullable=False, server_default="0" + ), + ) + op.add_column("rewards", sa.Column("max_per_member", sa.Integer(), nullable=True)) + + op.create_table( + "reward_redemptions", + sa.Column("id", sa.CHAR(length=36), primary_key=True), + sa.Column("tenant_id", sa.CHAR(length=36), nullable=False), + sa.Column("program_id", sa.CHAR(length=36), nullable=False), + sa.Column("reward_id", sa.CHAR(length=36), nullable=False), + sa.Column("member_id", sa.CHAR(length=36), nullable=False), + sa.Column("point_account_id", sa.CHAR(length=36), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("points_spent", sa.Integer(), nullable=False), + sa.Column("ledger_entry_id", sa.CHAR(length=36), nullable=True), + sa.Column("voucher_code", sa.String(length=50), nullable=False), + sa.Column("idempotency_key", sa.String(length=100), nullable=True), + sa.Column("reason", sa.String(length=500), nullable=True), + sa.Column("cancel_reason", sa.String(length=500), nullable=True), + sa.Column("fulfilled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("metadata_json", sa.JSON(), nullable=True), + sa.Column("actor_user_id", sa.String(length=100), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_reward_redemptions_tenant_idempotency", + ), + ) + op.create_index( + "ix_reward_redemptions_reward", + "reward_redemptions", + ["tenant_id", "reward_id"], + ) + op.create_index( + "ix_reward_redemptions_member", + "reward_redemptions", + ["tenant_id", "member_id"], + ) + op.create_index( + "ix_reward_redemptions_status", + "reward_redemptions", + ["tenant_id", "status"], + ) + + +def downgrade(): + op.drop_index("ix_reward_redemptions_status", table_name="reward_redemptions") + op.drop_index("ix_reward_redemptions_member", table_name="reward_redemptions") + op.drop_index("ix_reward_redemptions_reward", table_name="reward_redemptions") + op.drop_table("reward_redemptions") + op.drop_column("rewards", "max_per_member") + op.drop_column("rewards", "redeemed_count") + op.drop_column("rewards", "stock_limit") diff --git a/backend/services/loyalty/alembic/versions/0005_phase_74_campaigns.py b/backend/services/loyalty/alembic/versions/0005_phase_74_campaigns.py new file mode 100644 index 0000000..4be1bf5 --- /dev/null +++ b/backend/services/loyalty/alembic/versions/0005_phase_74_campaigns.py @@ -0,0 +1,49 @@ +"""Phase 7.4 — Campaign Engine (campaign applications).""" +from alembic import op +import sqlalchemy as sa + +revision = "0005_phase_74_campaigns" +down_revision = "0004_phase_73_rewards" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "campaign_applications", + sa.Column("id", sa.CHAR(length=36), primary_key=True), + sa.Column("tenant_id", sa.CHAR(length=36), nullable=False), + sa.Column("campaign_id", sa.CHAR(length=36), nullable=False), + sa.Column("program_id", sa.CHAR(length=36), nullable=False), + sa.Column("member_id", sa.CHAR(length=36), nullable=False), + sa.Column("point_account_id", sa.CHAR(length=36), nullable=False), + sa.Column("rule_version", sa.Integer(), nullable=False), + sa.Column( + "points_granted", sa.Integer(), nullable=False, server_default="0" + ), + sa.Column("ledger_entry_id", sa.CHAR(length=36), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("idempotency_key", sa.String(length=100), nullable=True), + sa.Column("actor_user_id", sa.String(length=100), nullable=True), + sa.Column("metadata_json", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_campaign_applications_tenant_idempotency", + ), + ) + op.create_index( + "ix_campaign_applications_campaign_member", + "campaign_applications", + ["tenant_id", "campaign_id", "member_id"], + ) + + +def downgrade(): + op.drop_index( + "ix_campaign_applications_campaign_member", + table_name="campaign_applications", + ) + op.drop_table("campaign_applications") diff --git a/backend/services/loyalty/alembic/versions/0006_phase_75_referral.py b/backend/services/loyalty/alembic/versions/0006_phase_75_referral.py new file mode 100644 index 0000000..17e8c13 --- /dev/null +++ b/backend/services/loyalty/alembic/versions/0006_phase_75_referral.py @@ -0,0 +1,139 @@ +"""Phase 7.5 — Referral Engine (programs, codes, attributions).""" +from alembic import op +import sqlalchemy as sa + +revision = "0006_phase_75_referral" +down_revision = "0005_phase_74_campaigns" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "referral_programs", + sa.Column("id", sa.CHAR(length=36), primary_key=True), + sa.Column("tenant_id", sa.CHAR(length=36), nullable=False), + sa.Column("program_id", sa.CHAR(length=36), nullable=False), + sa.Column("code", sa.String(length=50), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column( + "referrer_bonus_points", sa.Integer(), nullable=False, server_default="0" + ), + sa.Column( + "referee_bonus_points", sa.Integer(), nullable=False, server_default="0" + ), + sa.Column("max_referrals_per_member", sa.Integer(), nullable=True), + sa.Column( + "is_deleted", sa.Boolean(), nullable=False, server_default=sa.false() + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("deleted_by", sa.String(length=100), nullable=True), + sa.Column("created_by", sa.String(length=100), nullable=True), + sa.Column("updated_by", sa.String(length=100), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint( + "tenant_id", + "program_id", + "code", + name="uq_referral_programs_tenant_program_code", + ), + ) + op.create_index( + "ix_referral_programs_tenant_status", + "referral_programs", + ["tenant_id", "status"], + ) + op.create_index( + "ix_referral_programs_tenant_deleted", + "referral_programs", + ["tenant_id", "is_deleted"], + ) + + op.create_table( + "referral_codes", + sa.Column("id", sa.CHAR(length=36), primary_key=True), + sa.Column("tenant_id", sa.CHAR(length=36), nullable=False), + sa.Column("referral_program_id", sa.CHAR(length=36), nullable=False), + sa.Column("program_id", sa.CHAR(length=36), nullable=False), + sa.Column("member_id", sa.CHAR(length=36), nullable=False), + sa.Column("code", sa.String(length=50), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("uses_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_by", sa.String(length=100), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint( + "tenant_id", "code", name="uq_referral_codes_tenant_code" + ), + ) + op.create_index( + "ix_referral_codes_program_member", + "referral_codes", + ["tenant_id", "referral_program_id", "member_id"], + ) + + op.create_table( + "referral_attributions", + sa.Column("id", sa.CHAR(length=36), primary_key=True), + sa.Column("tenant_id", sa.CHAR(length=36), nullable=False), + sa.Column("referral_program_id", sa.CHAR(length=36), nullable=False), + sa.Column("referral_code_id", sa.CHAR(length=36), nullable=False), + sa.Column("program_id", sa.CHAR(length=36), nullable=False), + sa.Column("referrer_member_id", sa.CHAR(length=36), nullable=False), + sa.Column("referee_member_id", sa.CHAR(length=36), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("referrer_ledger_entry_id", sa.CHAR(length=36), nullable=True), + sa.Column("referee_ledger_entry_id", sa.CHAR(length=36), nullable=True), + sa.Column("idempotency_key", sa.String(length=100), nullable=True), + sa.Column("converted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("rewarded_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("cancel_reason", sa.String(length=500), nullable=True), + sa.Column("actor_user_id", sa.String(length=100), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint( + "tenant_id", + "referral_program_id", + "referee_member_id", + name="uq_referral_attributions_tenant_program_referee", + ), + sa.UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_referral_attributions_tenant_idempotency", + ), + ) + op.create_index( + "ix_referral_attributions_referrer", + "referral_attributions", + ["tenant_id", "referral_program_id", "referrer_member_id"], + ) + op.create_index( + "ix_referral_attributions_status", + "referral_attributions", + ["tenant_id", "status"], + ) + + +def downgrade(): + op.drop_index( + "ix_referral_attributions_status", table_name="referral_attributions" + ) + op.drop_index( + "ix_referral_attributions_referrer", table_name="referral_attributions" + ) + op.drop_table("referral_attributions") + + op.drop_index("ix_referral_codes_program_member", table_name="referral_codes") + op.drop_table("referral_codes") + + op.drop_index( + "ix_referral_programs_tenant_deleted", table_name="referral_programs" + ) + op.drop_index( + "ix_referral_programs_tenant_status", table_name="referral_programs" + ) + op.drop_table("referral_programs") diff --git a/backend/services/loyalty/alembic/versions/0007_phase_76_wallet.py b/backend/services/loyalty/alembic/versions/0007_phase_76_wallet.py new file mode 100644 index 0000000..7672f47 --- /dev/null +++ b/backend/services/loyalty/alembic/versions/0007_phase_76_wallet.py @@ -0,0 +1,110 @@ +"""Phase 7.6 — Wallet Engine (accounts + immutable ledger).""" +from alembic import op +import sqlalchemy as sa + +revision = "0007_phase_76_wallet" +down_revision = "0006_phase_75_referral" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "wallet_accounts", + sa.Column("id", sa.CHAR(length=36), primary_key=True), + sa.Column("tenant_id", sa.CHAR(length=36), nullable=False), + sa.Column("program_id", sa.CHAR(length=36), nullable=False), + sa.Column("member_id", sa.CHAR(length=36), nullable=False), + sa.Column("account_number", sa.String(length=50), nullable=False), + sa.Column( + "currency_code", + sa.String(length=3), + nullable=False, + server_default="IRR", + ), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column( + "is_deleted", sa.Boolean(), nullable=False, server_default=sa.false() + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("deleted_by", sa.String(length=100), nullable=True), + sa.Column("created_by", sa.String(length=100), nullable=True), + sa.Column("updated_by", sa.String(length=100), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint( + "tenant_id", + "account_number", + name="uq_wallet_accounts_tenant_account_number", + ), + sa.UniqueConstraint( + "tenant_id", + "program_id", + "member_id", + name="uq_wallet_accounts_tenant_program_member", + ), + ) + op.create_index( + "ix_wallet_accounts_program", "wallet_accounts", ["tenant_id", "program_id"] + ) + op.create_index( + "ix_wallet_accounts_member", "wallet_accounts", ["tenant_id", "member_id"] + ) + op.create_index( + "ix_wallet_accounts_tenant_deleted", + "wallet_accounts", + ["tenant_id", "is_deleted"], + ) + + op.create_table( + "wallet_ledger_entries", + sa.Column("id", sa.CHAR(length=36), primary_key=True), + sa.Column("tenant_id", sa.CHAR(length=36), nullable=False), + sa.Column("wallet_account_id", sa.CHAR(length=36), nullable=False), + sa.Column("program_id", sa.CHAR(length=36), nullable=False), + sa.Column("member_id", sa.CHAR(length=36), nullable=False), + sa.Column("entry_type", sa.String(length=30), nullable=False), + sa.Column("amount", sa.Integer(), nullable=False), + sa.Column("balance_after", sa.Integer(), nullable=False), + sa.Column("idempotency_key", sa.String(length=100), nullable=True), + sa.Column("reference_type", sa.String(length=50), nullable=True), + sa.Column("reference_id", sa.String(length=100), nullable=True), + sa.Column("reason", sa.String(length=500), nullable=True), + sa.Column("metadata_json", sa.JSON(), nullable=True), + sa.Column("actor_user_id", sa.String(length=100), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_wallet_ledger_tenant_idempotency", + ), + ) + op.create_index( + "ix_wallet_ledger_account", + "wallet_ledger_entries", + ["tenant_id", "wallet_account_id"], + ) + op.create_index( + "ix_wallet_ledger_member", "wallet_ledger_entries", ["tenant_id", "member_id"] + ) + op.create_index( + "ix_wallet_ledger_type", "wallet_ledger_entries", ["tenant_id", "entry_type"] + ) + op.create_index( + "ix_wallet_ledger_created", + "wallet_ledger_entries", + ["tenant_id", "created_at"], + ) + + +def downgrade(): + op.drop_index("ix_wallet_ledger_created", table_name="wallet_ledger_entries") + op.drop_index("ix_wallet_ledger_type", table_name="wallet_ledger_entries") + op.drop_index("ix_wallet_ledger_member", table_name="wallet_ledger_entries") + op.drop_index("ix_wallet_ledger_account", table_name="wallet_ledger_entries") + op.drop_table("wallet_ledger_entries") + + op.drop_index("ix_wallet_accounts_tenant_deleted", table_name="wallet_accounts") + op.drop_index("ix_wallet_accounts_member", table_name="wallet_accounts") + op.drop_index("ix_wallet_accounts_program", table_name="wallet_accounts") + op.drop_table("wallet_accounts") diff --git a/backend/services/loyalty/app/__init__.py b/backend/services/loyalty/app/__init__.py index 0817bb0..de74b76 100644 --- a/backend/services/loyalty/app/__init__.py +++ b/backend/services/loyalty/app/__init__.py @@ -1 +1 @@ -__version__ = "0.7.1.0" +__version__ = "0.7.6.0" diff --git a/backend/services/loyalty/app/api/v1/__init__.py b/backend/services/loyalty/app/api/v1/__init__.py index 42ca24d..f1bcf52 100644 --- a/backend/services/loyalty/app/api/v1/__init__.py +++ b/backend/services/loyalty/app/api/v1/__init__.py @@ -3,11 +3,17 @@ from fastapi import APIRouter from app.api.v1 import ( audit, campaigns, + ledger, members, point_accounts, programs, + redemptions, + referral_codes, + referral_programs, + referrals, rewards, tiers, + wallets, ) api_router = APIRouter() @@ -17,6 +23,18 @@ api_router.include_router(members.router, prefix="/members", tags=["members"]) api_router.include_router( point_accounts.router, prefix="/point-accounts", tags=["point-accounts"] ) +api_router.include_router(ledger.router, prefix="/point-accounts", tags=["ledger"]) api_router.include_router(rewards.router, prefix="/rewards", tags=["rewards"]) +api_router.include_router( + redemptions.router, prefix="/redemptions", tags=["redemptions"] +) api_router.include_router(campaigns.router, prefix="/campaigns", tags=["campaigns"]) +api_router.include_router( + referral_programs.router, prefix="/referral-programs", tags=["referral-programs"] +) +api_router.include_router( + referral_codes.router, prefix="/referral-codes", tags=["referral-codes"] +) +api_router.include_router(referrals.router, prefix="/referrals", tags=["referrals"]) +api_router.include_router(wallets.router, prefix="/wallets", tags=["wallets"]) api_router.include_router(audit.router, prefix="/audit", tags=["audit"]) diff --git a/backend/services/loyalty/app/api/v1/campaigns.py b/backend/services/loyalty/app/api/v1/campaigns.py index 4b3956c..01f2d8a 100644 --- a/backend/services/loyalty/app/api/v1/campaigns.py +++ b/backend/services/loyalty/app/api/v1/campaigns.py @@ -1,4 +1,4 @@ -"""Campaign APIs — Phase 7.0 shell.""" +"""Campaign APIs — CRUD (7.0) + lifecycle, rules versioning, and apply (7.4).""" from __future__ import annotations from uuid import UUID @@ -9,12 +9,27 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_db, get_pagination, require_tenant from app.api.permissions import require_permissions from app.permissions.definitions import ( + LOYALTY_CAMPAIGNS_ACTIVATE, + LOYALTY_CAMPAIGNS_APPLICATIONS_VIEW, + LOYALTY_CAMPAIGNS_APPLY, LOYALTY_CAMPAIGNS_CREATE, LOYALTY_CAMPAIGNS_DELETE, + LOYALTY_CAMPAIGNS_EXPIRE, + LOYALTY_CAMPAIGNS_PAUSE, + LOYALTY_CAMPAIGNS_RESUME, + LOYALTY_CAMPAIGNS_RULES_UPDATE, + LOYALTY_CAMPAIGNS_SCHEDULE, LOYALTY_CAMPAIGNS_UPDATE, LOYALTY_CAMPAIGNS_VIEW, ) +from app.schemas.campaigns import ( + ApplyCampaignRequest, + CampaignApplicationPage, + CampaignApplicationRead, + CampaignRulesUpdateRequest, +) from app.schemas.foundation import CampaignCreate, CampaignRead, CampaignUpdate +from app.services.campaign_engine import CampaignEngineService from app.services.foundation import CampaignService from shared.pagination import PaginationParams from shared.security import CurrentUser @@ -77,3 +92,103 @@ async def soft_delete_campaign( return await CampaignService(db).soft_delete( tenant_id, campaign_id, actor=user ) + + +@router.post("/{campaign_id}/schedule", response_model=CampaignRead) +async def schedule_campaign( + campaign_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_CAMPAIGNS_SCHEDULE)), +): + return await CampaignEngineService(db).schedule(tenant_id, campaign_id, actor=user) + + +@router.post("/{campaign_id}/activate", response_model=CampaignRead) +async def activate_campaign( + campaign_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_CAMPAIGNS_ACTIVATE)), +): + return await CampaignEngineService(db).activate(tenant_id, campaign_id, actor=user) + + +@router.post("/{campaign_id}/pause", response_model=CampaignRead) +async def pause_campaign( + campaign_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_CAMPAIGNS_PAUSE)), +): + return await CampaignEngineService(db).pause(tenant_id, campaign_id, actor=user) + + +@router.post("/{campaign_id}/resume", response_model=CampaignRead) +async def resume_campaign( + campaign_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_CAMPAIGNS_RESUME)), +): + return await CampaignEngineService(db).resume(tenant_id, campaign_id, actor=user) + + +@router.post("/{campaign_id}/expire", response_model=CampaignRead) +async def expire_campaign( + campaign_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_CAMPAIGNS_EXPIRE)), +): + return await CampaignEngineService(db).expire(tenant_id, campaign_id, actor=user) + + +@router.post("/{campaign_id}/rules", response_model=CampaignRead) +async def update_campaign_rules( + campaign_id: UUID, + body: CampaignRulesUpdateRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_CAMPAIGNS_RULES_UPDATE)), +): + return await CampaignEngineService(db).update_rules( + tenant_id, campaign_id, body, actor=user + ) + + +@router.post( + "/{campaign_id}/apply", + response_model=CampaignApplicationRead, + status_code=status.HTTP_201_CREATED, +) +async def apply_campaign( + campaign_id: UUID, + body: ApplyCampaignRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_CAMPAIGNS_APPLY)), +): + return await CampaignEngineService(db).apply( + tenant_id, campaign_id, body, actor=user + ) + + +@router.get("/{campaign_id}/applications", response_model=CampaignApplicationPage) +async def list_campaign_applications( + campaign_id: UUID, + pagination: PaginationParams = Depends(get_pagination), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends( + require_permissions( + LOYALTY_CAMPAIGNS_APPLICATIONS_VIEW, LOYALTY_CAMPAIGNS_VIEW + ) + ), +): + rows, total = await CampaignEngineService(db).list_applications( + tenant_id, campaign_id, offset=pagination.offset, limit=pagination.page_size + ) + return CampaignApplicationPage( + items=rows, total=total, offset=pagination.offset, limit=pagination.page_size + ) diff --git a/backend/services/loyalty/app/api/v1/ledger.py b/backend/services/loyalty/app/api/v1/ledger.py new file mode 100644 index 0000000..0e757be --- /dev/null +++ b/backend/services/loyalty/app/api/v1/ledger.py @@ -0,0 +1,115 @@ +"""Point ledger APIs — Phase 7.2.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, require_tenant +from app.api.permissions import require_permissions +from app.models.types import LedgerEntryType +from app.permissions.definitions import ( + LOYALTY_POINTS_ADJUST, + LOYALTY_POINTS_EARN, + LOYALTY_POINTS_EXPIRE, + LOYALTY_POINTS_REDEEM, + LOYALTY_POINTS_VIEW, +) +from app.schemas.ledger import ( + PointAdjustRequest, + PointBalanceRead, + PointEarnRequest, + PointExpireRequest, + PointLedgerEntryRead, + PointLedgerPage, + PointRedeemRequest, +) +from app.services.point_engine import PointEngineService +from shared.security import CurrentUser + +router = APIRouter() + + +@router.get("/{account_id}/balance", response_model=PointBalanceRead) +async def get_balance( + account_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOYALTY_POINTS_VIEW)), +): + return await PointEngineService(db).get_balance(tenant_id, account_id) + + +@router.get("/{account_id}/ledger", response_model=PointLedgerPage) +async def list_ledger( + account_id: UUID, + offset: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=200), + entry_type: LedgerEntryType | None = None, + q: str | None = Query(default=None, max_length=100), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOYALTY_POINTS_VIEW)), +): + rows, total = await PointEngineService(db).list_entries( + tenant_id, + account_id, + entry_type=entry_type, + offset=offset, + limit=limit, + q=q, + ) + return PointLedgerPage( + items=rows, total=total, offset=offset, limit=limit + ) + + +@router.post("/{account_id}/earn", response_model=PointLedgerEntryRead) +async def earn_points( + account_id: UUID, + body: PointEarnRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_POINTS_EARN)), +): + return await PointEngineService(db).earn(tenant_id, account_id, body, actor=user) + + +@router.post("/{account_id}/redeem", response_model=PointLedgerEntryRead) +async def redeem_points( + account_id: UUID, + body: PointRedeemRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_POINTS_REDEEM)), +): + return await PointEngineService(db).redeem( + tenant_id, account_id, body, actor=user + ) + + +@router.post("/{account_id}/adjust", response_model=PointLedgerEntryRead) +async def adjust_points( + account_id: UUID, + body: PointAdjustRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_POINTS_ADJUST)), +): + return await PointEngineService(db).adjust( + tenant_id, account_id, body, actor=user + ) + + +@router.post("/{account_id}/expire-points", response_model=PointLedgerEntryRead) +async def expire_points( + account_id: UUID, + body: PointExpireRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_POINTS_EXPIRE)), +): + return await PointEngineService(db).expire( + tenant_id, account_id, body, actor=user + ) diff --git a/backend/services/loyalty/app/api/v1/redemptions.py b/backend/services/loyalty/app/api/v1/redemptions.py new file mode 100644 index 0000000..3ff4f37 --- /dev/null +++ b/backend/services/loyalty/app/api/v1/redemptions.py @@ -0,0 +1,78 @@ +"""Reward redemption lifecycle APIs — Phase 7.3.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.models.types import RedemptionStatus +from app.permissions.definitions import ( + LOYALTY_REDEMPTIONS_VIEW, + LOYALTY_REWARDS_CANCEL, + LOYALTY_REWARDS_FULFILL, +) +from app.schemas.rewards import CancelRequest, RewardRedemptionPage, RewardRedemptionRead +from app.services.rewards_engine import RewardsEngineService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.get("", response_model=RewardRedemptionPage) +async def list_redemptions( + status: RedemptionStatus | None = Query(default=None), + member_id: UUID | None = Query(default=None), + reward_id: UUID | None = Query(default=None), + pagination: PaginationParams = Depends(get_pagination), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOYALTY_REDEMPTIONS_VIEW)), +): + rows, total = await RewardsEngineService(db).list( + tenant_id, + reward_id=reward_id, + member_id=member_id, + status=status, + offset=pagination.offset, + limit=pagination.page_size, + ) + return RewardRedemptionPage( + items=rows, total=total, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{redemption_id}", response_model=RewardRedemptionRead) +async def get_redemption( + redemption_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOYALTY_REDEMPTIONS_VIEW)), +): + return await RewardsEngineService(db).get(tenant_id, redemption_id) + + +@router.post("/{redemption_id}/fulfill", response_model=RewardRedemptionRead) +async def fulfill_redemption( + redemption_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_REWARDS_FULFILL)), +): + return await RewardsEngineService(db).fulfill(tenant_id, redemption_id, actor=user) + + +@router.post("/{redemption_id}/cancel", response_model=RewardRedemptionRead) +async def cancel_redemption( + redemption_id: UUID, + body: CancelRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_REWARDS_CANCEL)), +): + return await RewardsEngineService(db).cancel( + tenant_id, redemption_id, body, actor=user + ) diff --git a/backend/services/loyalty/app/api/v1/referral_codes.py b/backend/services/loyalty/app/api/v1/referral_codes.py new file mode 100644 index 0000000..d8bb868 --- /dev/null +++ b/backend/services/loyalty/app/api/v1/referral_codes.py @@ -0,0 +1,74 @@ +"""Referral Code APIs — issue/revoke codes for members (Phase 7.5).""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + LOYALTY_REFERRAL_CODES_CREATE, + LOYALTY_REFERRAL_CODES_REVOKE, + LOYALTY_REFERRAL_CODES_VIEW, +) +from app.schemas.referral import ( + ReferralCodeIssueRequest, + ReferralCodePage, + ReferralCodeRead, +) +from app.services.referral_engine import ReferralEngineService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=ReferralCodeRead, status_code=status.HTTP_201_CREATED) +async def issue_referral_code( + body: ReferralCodeIssueRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_REFERRAL_CODES_CREATE)), +): + return await ReferralEngineService(db).issue_code(tenant_id, body, actor=user) + + +@router.get("", response_model=ReferralCodePage) +async def list_referral_codes( + referral_program_id: UUID | None = None, + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOYALTY_REFERRAL_CODES_VIEW)), +): + rows, total = await ReferralEngineService(db).list_codes( + tenant_id, + offset=pagination.offset, + limit=pagination.page_size, + referral_program_id=referral_program_id, + ) + return ReferralCodePage( + items=rows, total=total, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{code_id}", response_model=ReferralCodeRead) +async def get_referral_code( + code_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOYALTY_REFERRAL_CODES_VIEW)), +): + return await ReferralEngineService(db).get_code(tenant_id, code_id) + + +@router.post("/{code_id}/revoke", response_model=ReferralCodeRead) +async def revoke_referral_code( + code_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_REFERRAL_CODES_REVOKE)), +): + return await ReferralEngineService(db).revoke_code(tenant_id, code_id, actor=user) diff --git a/backend/services/loyalty/app/api/v1/referral_programs.py b/backend/services/loyalty/app/api/v1/referral_programs.py new file mode 100644 index 0000000..da8d991 --- /dev/null +++ b/backend/services/loyalty/app/api/v1/referral_programs.py @@ -0,0 +1,83 @@ +"""Referral Program APIs — CRUD (Phase 7.5).""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + LOYALTY_REFERRAL_PROGRAMS_CREATE, + LOYALTY_REFERRAL_PROGRAMS_DELETE, + LOYALTY_REFERRAL_PROGRAMS_UPDATE, + LOYALTY_REFERRAL_PROGRAMS_VIEW, +) +from app.schemas.referral import ( + ReferralProgramCreate, + ReferralProgramRead, + ReferralProgramUpdate, +) +from app.services.referral_engine import ReferralEngineService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=ReferralProgramRead, status_code=status.HTTP_201_CREATED) +async def create_referral_program( + body: ReferralProgramCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_REFERRAL_PROGRAMS_CREATE)), +): + return await ReferralEngineService(db).create_program(tenant_id, body, actor=user) + + +@router.get("", response_model=list[ReferralProgramRead]) +async def list_referral_programs( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOYALTY_REFERRAL_PROGRAMS_VIEW)), +): + return await ReferralEngineService(db).list_programs( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{referral_program_id}", response_model=ReferralProgramRead) +async def get_referral_program( + referral_program_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOYALTY_REFERRAL_PROGRAMS_VIEW)), +): + return await ReferralEngineService(db).get_program(tenant_id, referral_program_id) + + +@router.patch("/{referral_program_id}", response_model=ReferralProgramRead) +async def update_referral_program( + referral_program_id: UUID, + body: ReferralProgramUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_REFERRAL_PROGRAMS_UPDATE)), +): + return await ReferralEngineService(db).update_program( + tenant_id, referral_program_id, body, actor=user + ) + + +@router.post("/{referral_program_id}/delete", response_model=ReferralProgramRead) +async def soft_delete_referral_program( + referral_program_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_REFERRAL_PROGRAMS_DELETE)), +): + return await ReferralEngineService(db).soft_delete_program( + tenant_id, referral_program_id, actor=user + ) diff --git a/backend/services/loyalty/app/api/v1/referrals.py b/backend/services/loyalty/app/api/v1/referrals.py new file mode 100644 index 0000000..d4635d0 --- /dev/null +++ b/backend/services/loyalty/app/api/v1/referrals.py @@ -0,0 +1,104 @@ +"""Referral Attribution APIs — attribute/convert/reward/cancel (Phase 7.5).""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + LOYALTY_REFERRALS_ATTRIBUTE, + LOYALTY_REFERRALS_CANCEL, + LOYALTY_REFERRALS_CONVERT, + LOYALTY_REFERRALS_REWARD, + LOYALTY_REFERRALS_VIEW, +) +from app.schemas.referral import ( + ReferralAttributeRequest, + ReferralAttributionPage, + ReferralAttributionRead, + ReferralCancelRequest, +) +from app.services.referral_engine import ReferralEngineService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post( + "/attribute", + response_model=ReferralAttributionRead, + status_code=status.HTTP_201_CREATED, +) +async def attribute_referral( + body: ReferralAttributeRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_REFERRALS_ATTRIBUTE)), +): + return await ReferralEngineService(db).attribute(tenant_id, body, actor=user) + + +@router.get("", response_model=ReferralAttributionPage) +async def list_referral_attributions( + referral_program_id: UUID | None = None, + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOYALTY_REFERRALS_VIEW)), +): + rows, total = await ReferralEngineService(db).list_attributions( + tenant_id, + offset=pagination.offset, + limit=pagination.page_size, + referral_program_id=referral_program_id, + ) + return ReferralAttributionPage( + items=rows, total=total, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{attribution_id}", response_model=ReferralAttributionRead) +async def get_referral_attribution( + attribution_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOYALTY_REFERRALS_VIEW)), +): + return await ReferralEngineService(db).get_attribution(tenant_id, attribution_id) + + +@router.post("/{attribution_id}/convert", response_model=ReferralAttributionRead) +async def convert_referral( + attribution_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_REFERRALS_CONVERT)), +): + return await ReferralEngineService(db).convert(tenant_id, attribution_id, actor=user) + + +@router.post("/{attribution_id}/reward", response_model=ReferralAttributionRead) +async def reward_referral( + attribution_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_REFERRALS_REWARD)), +): + return await ReferralEngineService(db).reward(tenant_id, attribution_id, actor=user) + + +@router.post("/{attribution_id}/cancel", response_model=ReferralAttributionRead) +async def cancel_referral( + attribution_id: UUID, + body: ReferralCancelRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_REFERRALS_CANCEL)), +): + return await ReferralEngineService(db).cancel( + tenant_id, attribution_id, body, actor=user + ) diff --git a/backend/services/loyalty/app/api/v1/rewards.py b/backend/services/loyalty/app/api/v1/rewards.py index b3777ac..6f7e04f 100644 --- a/backend/services/loyalty/app/api/v1/rewards.py +++ b/backend/services/loyalty/app/api/v1/rewards.py @@ -1,4 +1,4 @@ -"""Reward catalog APIs — Phase 7.0 shell.""" +"""Reward catalog and redemption APIs — Phase 7.0 shell + Phase 7.3 rewards engine.""" from __future__ import annotations from uuid import UUID @@ -9,13 +9,17 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_db, get_pagination, require_tenant from app.api.permissions import require_permissions from app.permissions.definitions import ( + LOYALTY_REDEMPTIONS_VIEW, LOYALTY_REWARDS_CREATE, LOYALTY_REWARDS_DELETE, + LOYALTY_REWARDS_REDEEM, LOYALTY_REWARDS_UPDATE, LOYALTY_REWARDS_VIEW, ) from app.schemas.foundation import RewardCreate, RewardRead, RewardUpdate +from app.schemas.rewards import RedeemRequest, RewardRedemptionPage, RewardRedemptionRead from app.services.foundation import RewardService +from app.services.rewards_engine import RewardsEngineService from shared.pagination import PaginationParams from shared.security import CurrentUser @@ -73,3 +77,37 @@ async def soft_delete_reward( user: CurrentUser = Depends(require_permissions(LOYALTY_REWARDS_DELETE)), ): return await RewardService(db).soft_delete(tenant_id, reward_id, actor=user) + + +@router.post( + "/{reward_id}/redeem", + response_model=RewardRedemptionRead, + status_code=status.HTTP_201_CREATED, +) +async def redeem_reward( + reward_id: UUID, + body: RedeemRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_REWARDS_REDEEM)), +): + return await RewardsEngineService(db).redeem(tenant_id, reward_id, body, actor=user) + + +@router.get("/{reward_id}/redemptions", response_model=RewardRedemptionPage) +async def list_reward_redemptions( + reward_id: UUID, + pagination: PaginationParams = Depends(get_pagination), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOYALTY_REDEMPTIONS_VIEW)), +): + rows, total = await RewardsEngineService(db).list( + tenant_id, + reward_id=reward_id, + offset=pagination.offset, + limit=pagination.page_size, + ) + return RewardRedemptionPage( + items=rows, total=total, offset=pagination.offset, limit=pagination.page_size + ) diff --git a/backend/services/loyalty/app/api/v1/wallets.py b/backend/services/loyalty/app/api/v1/wallets.py new file mode 100644 index 0000000..3cb11c3 --- /dev/null +++ b/backend/services/loyalty/app/api/v1/wallets.py @@ -0,0 +1,165 @@ +"""Wallet APIs — accounts + immutable ledger (Phase 7.6).""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.models.types import WalletLedgerEntryType +from app.permissions.definitions import ( + LOYALTY_WALLETS_ADJUST, + LOYALTY_WALLETS_CREATE, + LOYALTY_WALLETS_CREDIT, + LOYALTY_WALLETS_DEBIT, + LOYALTY_WALLETS_DELETE, + LOYALTY_WALLETS_MANAGE, + LOYALTY_WALLETS_TRANSFER, + LOYALTY_WALLETS_VIEW, +) +from app.schemas.wallet import ( + WalletAccountCreate, + WalletAccountRead, + WalletAccountUpdate, + WalletAdjustRequest, + WalletBalanceRead, + WalletCreditRequest, + WalletDebitRequest, + WalletLedgerPage, + WalletLedgerEntryRead, + WalletTransferRequest, + WalletTransferResult, +) +from app.services.wallet_engine import WalletEngineService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=WalletAccountRead, status_code=status.HTTP_201_CREATED) +async def open_wallet( + body: WalletAccountCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_WALLETS_CREATE)), +): + return await WalletEngineService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[WalletAccountRead]) +async def list_wallets( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOYALTY_WALLETS_VIEW)), +): + return await WalletEngineService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{wallet_id}", response_model=WalletAccountRead) +async def get_wallet( + wallet_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOYALTY_WALLETS_VIEW)), +): + return await WalletEngineService(db).get(tenant_id, wallet_id) + + +@router.get("/{wallet_id}/balance", response_model=WalletBalanceRead) +async def get_wallet_balance( + wallet_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOYALTY_WALLETS_VIEW)), +): + return await WalletEngineService(db).get_balance(tenant_id, wallet_id) + + +@router.get("/{wallet_id}/ledger", response_model=WalletLedgerPage) +async def list_wallet_ledger( + wallet_id: UUID, + offset: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=200), + entry_type: WalletLedgerEntryType | None = None, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOYALTY_WALLETS_VIEW)), +): + rows, total = await WalletEngineService(db).list_ledger( + tenant_id, wallet_id, entry_type=entry_type, offset=offset, limit=limit + ) + return WalletLedgerPage(items=rows, total=total, offset=offset, limit=limit) + + +@router.patch("/{wallet_id}", response_model=WalletAccountRead) +async def update_wallet( + wallet_id: UUID, + body: WalletAccountUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_WALLETS_MANAGE)), +): + return await WalletEngineService(db).update(tenant_id, wallet_id, body, actor=user) + + +@router.post("/{wallet_id}/credit", response_model=WalletLedgerEntryRead) +async def credit_wallet( + wallet_id: UUID, + body: WalletCreditRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_WALLETS_CREDIT)), +): + return await WalletEngineService(db).credit(tenant_id, wallet_id, body, actor=user) + + +@router.post("/{wallet_id}/debit", response_model=WalletLedgerEntryRead) +async def debit_wallet( + wallet_id: UUID, + body: WalletDebitRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_WALLETS_DEBIT)), +): + return await WalletEngineService(db).debit(tenant_id, wallet_id, body, actor=user) + + +@router.post("/{wallet_id}/adjust", response_model=WalletLedgerEntryRead) +async def adjust_wallet( + wallet_id: UUID, + body: WalletAdjustRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_WALLETS_ADJUST)), +): + return await WalletEngineService(db).adjust(tenant_id, wallet_id, body, actor=user) + + +@router.post("/{wallet_id}/transfer", response_model=WalletTransferResult) +async def transfer_wallet( + wallet_id: UUID, + body: WalletTransferRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_WALLETS_TRANSFER)), +): + source_entry, target_entry = await WalletEngineService(db).transfer( + tenant_id, wallet_id, body, actor=user + ) + return WalletTransferResult(source_entry=source_entry, target_entry=target_entry) + + +@router.post("/{wallet_id}/delete", response_model=WalletAccountRead) +async def close_wallet( + wallet_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOYALTY_WALLETS_DELETE)), +): + return await WalletEngineService(db).soft_delete(tenant_id, wallet_id, actor=user) diff --git a/backend/services/loyalty/app/events/types.py b/backend/services/loyalty/app/events/types.py index d4320e0..4b293bf 100644 --- a/backend/services/loyalty/app/events/types.py +++ b/backend/services/loyalty/app/events/types.py @@ -25,9 +25,38 @@ class LoyaltyEventType(str, enum.Enum): POINT_ACCOUNT_OPENED = "loyalty.point_account.opened" POINT_ACCOUNT_UPDATED = "loyalty.point_account.updated" POINT_ACCOUNT_DELETED = "loyalty.point_account.deleted" + POINTS_EARNED = "loyalty.points.earned" + POINTS_REDEEMED = "loyalty.points.redeemed" + POINTS_ADJUSTED = "loyalty.points.adjusted" + POINTS_EXPIRED = "loyalty.points.expired" REWARD_CREATED = "loyalty.reward.created" REWARD_UPDATED = "loyalty.reward.updated" REWARD_DELETED = "loyalty.reward.deleted" + REWARD_REDEEMED = "loyalty.reward.redeemed" + REWARD_FULFILLED = "loyalty.reward.fulfilled" + REWARD_REDEMPTION_CANCELLED = "loyalty.reward.redemption_cancelled" CAMPAIGN_CREATED = "loyalty.campaign.created" CAMPAIGN_UPDATED = "loyalty.campaign.updated" CAMPAIGN_DELETED = "loyalty.campaign.deleted" + CAMPAIGN_SCHEDULED = "loyalty.campaign.scheduled" + CAMPAIGN_ACTIVATED = "loyalty.campaign.activated" + CAMPAIGN_PAUSED = "loyalty.campaign.paused" + CAMPAIGN_RESUMED = "loyalty.campaign.resumed" + CAMPAIGN_EXPIRED = "loyalty.campaign.expired" + CAMPAIGN_RULES_UPDATED = "loyalty.campaign.rules_updated" + CAMPAIGN_APPLIED = "loyalty.campaign.applied" + REFERRAL_PROGRAM_CREATED = "loyalty.referral.program_created" + REFERRAL_PROGRAM_UPDATED = "loyalty.referral.program_updated" + REFERRAL_PROGRAM_DELETED = "loyalty.referral.program_deleted" + REFERRAL_CODE_ISSUED = "loyalty.referral.code_issued" + REFERRAL_CODE_REVOKED = "loyalty.referral.code_revoked" + REFERRAL_ATTRIBUTED = "loyalty.referral.attributed" + REFERRAL_CONVERTED = "loyalty.referral.converted" + REFERRAL_REWARDED = "loyalty.referral.rewarded" + REFERRAL_CANCELLED = "loyalty.referral.cancelled" + WALLET_OPENED = "loyalty.wallet.opened" + WALLET_CREDITED = "loyalty.wallet.credited" + WALLET_DEBITED = "loyalty.wallet.debited" + WALLET_ADJUSTED = "loyalty.wallet.adjusted" + WALLET_TRANSFERRED = "loyalty.wallet.transferred" + WALLET_CLOSED = "loyalty.wallet.closed" diff --git a/backend/services/loyalty/app/models/__init__.py b/backend/services/loyalty/app/models/__init__.py index 6939882..37d4de3 100644 --- a/backend/services/loyalty/app/models/__init__.py +++ b/backend/services/loyalty/app/models/__init__.py @@ -9,4 +9,13 @@ from app.models.foundation import ( # noqa: F401 PointAccount, Reward, ) +from app.models.ledger import PointLedgerEntry # noqa: F401 from app.models.outbox import OutboxEvent # noqa: F401 +from app.models.rewards import RewardRedemption # noqa: F401 +from app.models.campaigns import CampaignApplication # noqa: F401 +from app.models.referral import ( # noqa: F401 + ReferralAttribution, + ReferralCode, + ReferralProgram, +) +from app.models.wallet import WalletAccount, WalletLedgerEntry # noqa: F401 diff --git a/backend/services/loyalty/app/models/campaigns.py b/backend/services/loyalty/app/models/campaigns.py new file mode 100644 index 0000000..bae9b71 --- /dev/null +++ b/backend/services/loyalty/app/models/campaigns.py @@ -0,0 +1,57 @@ +"""Campaign application tracking — Phase 7.4 Campaign Engine. + +Independent aggregate (no SQLAlchemy relationships), same as other Loyalty +aggregates. Points are always the ledger's responsibility; this row only +records that a campaign granted N points to a member at a given rule version. +""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import Index, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin +from app.models.types import CampaignApplicationStatus, GUID + + +class CampaignApplication( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, +): + """Record of a campaign's point grant applied to a member's point account.""" + + __tablename__ = "campaign_applications" + + campaign_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + point_account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + rule_version: Mapped[int] = mapped_column(Integer, nullable=False) + points_granted: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + ledger_entry_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + status: Mapped[CampaignApplicationStatus] = mapped_column( + default=CampaignApplicationStatus.APPLIED, nullable=False + ) + idempotency_key: Mapped[str | None] = mapped_column(String(100), nullable=True) + actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_campaign_applications_tenant_idempotency", + ), + Index( + "ix_campaign_applications_campaign_member", + "tenant_id", + "campaign_id", + "member_id", + ), + ) diff --git a/backend/services/loyalty/app/models/foundation.py b/backend/services/loyalty/app/models/foundation.py index ebd0a0f..9df061d 100644 --- a/backend/services/loyalty/app/models/foundation.py +++ b/backend/services/loyalty/app/models/foundation.py @@ -282,6 +282,9 @@ class Reward( 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) + stock_limit: Mapped[int | None] = mapped_column(Integer, nullable=True) + redeemed_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + max_per_member: Mapped[int | None] = mapped_column(Integer, nullable=True) __table_args__ = ( UniqueConstraint("tenant_id", "program_id", "code", name="uq_rewards_tenant_code"), diff --git a/backend/services/loyalty/app/models/ledger.py b/backend/services/loyalty/app/models/ledger.py new file mode 100644 index 0000000..d9b75e6 --- /dev/null +++ b/backend/services/loyalty/app/models/ledger.py @@ -0,0 +1,52 @@ +"""Immutable point ledger — Phase 7.2.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import TenantMixin, UUIDPrimaryKeyMixin +from app.models.types import GUID, LedgerEntryType + + +class PointLedgerEntry(UUIDPrimaryKeyMixin, TenantMixin, Base): + """Append-only ledger entry. Balance = SUM(signed amounts). Never update/delete.""" + + __tablename__ = "point_ledger_entries" + + point_account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + entry_type: Mapped[LedgerEntryType] = mapped_column(nullable=False) + # Signed amount: earn/adjust+ positive; redeem/expire/adjust- negative + amount: Mapped[int] = mapped_column(Integer, nullable=False) + balance_after: Mapped[int] = mapped_column(Integer, nullable=False) + idempotency_key: Mapped[str | None] = mapped_column(String(100), nullable=True) + reference_type: Mapped[str | None] = mapped_column(String(50), nullable=True) + reference_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + reason: Mapped[str | None] = mapped_column(String(500), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), 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__ = ( + UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_point_ledger_tenant_idempotency", + ), + Index("ix_point_ledger_account", "tenant_id", "point_account_id"), + Index("ix_point_ledger_member", "tenant_id", "member_id"), + Index("ix_point_ledger_type", "tenant_id", "entry_type"), + Index("ix_point_ledger_created", "tenant_id", "created_at"), + Index("ix_point_ledger_expires", "tenant_id", "expires_at"), + ) diff --git a/backend/services/loyalty/app/models/referral.py b/backend/services/loyalty/app/models/referral.py new file mode 100644 index 0000000..1ba7154 --- /dev/null +++ b/backend/services/loyalty/app/models/referral.py @@ -0,0 +1,158 @@ +"""Referral Engine aggregates — Phase 7.5. + +Independent aggregates (no SQLAlchemy relationships), same as other Loyalty +aggregates. Bonus points are always the ledger's responsibility; these rows +only record configuration, code ownership, and referrer→referee tracking. +""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ( + GUID, + ReferralAttributionStatus, + ReferralCodeStatus, + ReferralProgramStatus, +) + + +class ReferralProgram( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Referral program configuration — bonus rules for referrer/referee.""" + + __tablename__ = "referral_programs" + + 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) + status: Mapped[ReferralProgramStatus] = mapped_column( + default=ReferralProgramStatus.DRAFT, nullable=False + ) + referrer_bonus_points: Mapped[int] = mapped_column( + Integer, default=0, nullable=False + ) + referee_bonus_points: Mapped[int] = mapped_column( + Integer, default=0, nullable=False + ) + max_referrals_per_member: Mapped[int | None] = mapped_column( + Integer, nullable=True + ) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "program_id", + "code", + name="uq_referral_programs_tenant_program_code", + ), + Index("ix_referral_programs_tenant_status", "tenant_id", "status"), + Index("ix_referral_programs_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ReferralCode( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, +): + """Referral code owned by a member (the referrer). Status-only lifecycle.""" + + __tablename__ = "referral_codes" + + referral_program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[ReferralCodeStatus] = mapped_column( + default=ReferralCodeStatus.ACTIVE, nullable=False + ) + uses_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + created_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_referral_codes_tenant_code"), + Index( + "ix_referral_codes_program_member", + "tenant_id", + "referral_program_id", + "member_id", + ), + ) + + +class ReferralAttribution( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, +): + """Referrer -> referee link and its lifecycle through reward.""" + + __tablename__ = "referral_attributions" + + referral_program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + referral_code_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + referrer_member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + referee_member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + status: Mapped[ReferralAttributionStatus] = mapped_column( + default=ReferralAttributionStatus.PENDING, nullable=False + ) + referrer_ledger_entry_id: Mapped[uuid.UUID | None] = mapped_column( + GUID(), nullable=True + ) + referee_ledger_entry_id: Mapped[uuid.UUID | None] = mapped_column( + GUID(), nullable=True + ) + idempotency_key: Mapped[str | None] = mapped_column(String(100), nullable=True) + converted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + rewarded_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), 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) + actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "referral_program_id", + "referee_member_id", + name="uq_referral_attributions_tenant_program_referee", + ), + UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_referral_attributions_tenant_idempotency", + ), + Index( + "ix_referral_attributions_referrer", + "tenant_id", + "referral_program_id", + "referrer_member_id", + ), + Index("ix_referral_attributions_status", "tenant_id", "status"), + ) diff --git a/backend/services/loyalty/app/models/rewards.py b/backend/services/loyalty/app/models/rewards.py new file mode 100644 index 0000000..907b1a3 --- /dev/null +++ b/backend/services/loyalty/app/models/rewards.py @@ -0,0 +1,57 @@ +"""Reward redemption lifecycle aggregate — Phase 7.3.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin +from app.models.types import GUID, RedemptionStatus + + +class RewardRedemption( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, +): + """Reward redemption lifecycle record. Points ledger remains the source of truth.""" + + __tablename__ = "reward_redemptions" + + program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + reward_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + point_account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + status: Mapped[RedemptionStatus] = mapped_column( + default=RedemptionStatus.PENDING, nullable=False + ) + points_spent: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + ledger_entry_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + voucher_code: Mapped[str] = mapped_column(String(50), nullable=False) + idempotency_key: Mapped[str | None] = mapped_column(String(100), nullable=True) + reason: Mapped[str | None] = mapped_column(String(500), nullable=True) + cancel_reason: Mapped[str | None] = mapped_column(String(500), nullable=True) + fulfilled_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + cancelled_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_reward_redemptions_tenant_idempotency", + ), + Index("ix_reward_redemptions_reward", "tenant_id", "reward_id"), + Index("ix_reward_redemptions_member", "tenant_id", "member_id"), + Index("ix_reward_redemptions_status", "tenant_id", "status"), + ) diff --git a/backend/services/loyalty/app/models/types.py b/backend/services/loyalty/app/models/types.py index 20e281a..68c9489 100644 --- a/backend/services/loyalty/app/models/types.py +++ b/backend/services/loyalty/app/models/types.py @@ -73,6 +73,13 @@ class PointAccountStatus(str, enum.Enum): CLOSED = "closed" +class LedgerEntryType(str, enum.Enum): + EARN = "earn" + REDEEM = "redeem" + ADJUST = "adjust" + EXPIRE = "expire" + + class RewardType(str, enum.Enum): COUPON = "coupon" DISCOUNT = "discount" @@ -98,6 +105,50 @@ class CampaignStatus(str, enum.Enum): ARCHIVED = "archived" +class WalletAccountStatus(str, enum.Enum): + OPEN = "open" + FROZEN = "frozen" + CLOSED = "closed" + + +class WalletLedgerEntryType(str, enum.Enum): + CREDIT = "credit" + DEBIT = "debit" + ADJUST = "adjust" + TRANSFER_IN = "transfer_in" + TRANSFER_OUT = "transfer_out" + + +class RedemptionStatus(str, enum.Enum): + PENDING = "pending" + FULFILLED = "fulfilled" + CANCELLED = "cancelled" + EXPIRED = "expired" + + +class CampaignApplicationStatus(str, enum.Enum): + APPLIED = "applied" + REVERSED = "reversed" + + +class ReferralProgramStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + INACTIVE = "inactive" + + +class ReferralCodeStatus(str, enum.Enum): + ACTIVE = "active" + REVOKED = "revoked" + + +class ReferralAttributionStatus(str, enum.Enum): + PENDING = "pending" + CONVERTED = "converted" + REWARDED = "rewarded" + CANCELLED = "cancelled" + + class AuditAction(str, enum.Enum): CREATE = "create" UPDATE = "update" @@ -113,3 +164,25 @@ class AuditAction(str, enum.Enum): TRANSFER = "transfer" CANCEL = "cancel" EXPIRE = "expire" + EARN = "earn" + REDEEM = "redeem" + ADJUST = "adjust" + POINTS_EXPIRE = "points_expire" + REDEEM_REWARD = "redeem_reward" + FULFILL_REWARD = "fulfill_reward" + CANCEL_REDEMPTION = "cancel_redemption" + SCHEDULE = "schedule" + PAUSE = "pause" + APPLY = "apply" + RULES_UPDATE = "rules_update" + ISSUE_CODE = "issue_code" + REVOKE_CODE = "revoke_code" + ATTRIBUTE = "attribute" + CONVERT = "convert" + REWARD = "reward" + WALLET_OPEN = "wallet_open" + WALLET_CREDIT = "wallet_credit" + WALLET_DEBIT = "wallet_debit" + WALLET_ADJUST = "wallet_adjust" + WALLET_TRANSFER = "wallet_transfer" + WALLET_CLOSE = "wallet_close" diff --git a/backend/services/loyalty/app/models/wallet.py b/backend/services/loyalty/app/models/wallet.py new file mode 100644 index 0000000..fcaa52f --- /dev/null +++ b/backend/services/loyalty/app/models/wallet.py @@ -0,0 +1,98 @@ +"""Wallet Engine aggregates — Phase 7.6. + +Mirrors the Point Engine's immutability discipline: WalletAccount is a shell +only (no mutable balance column). Balance = SUM(signed WalletLedgerEntry +amounts). WalletLedgerEntry rows are append-only — never update/delete. +""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import GUID, WalletAccountStatus, WalletLedgerEntryType + + +class WalletAccount( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Wallet account shell — balance only via immutable ledger (Phase 7.6).""" + + __tablename__ = "wallet_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) + currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False) + status: Mapped[WalletAccountStatus] = mapped_column( + default=WalletAccountStatus.OPEN, nullable=False + ) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "account_number", + name="uq_wallet_accounts_tenant_account_number", + ), + UniqueConstraint( + "tenant_id", + "program_id", + "member_id", + name="uq_wallet_accounts_tenant_program_member", + ), + Index("ix_wallet_accounts_program", "tenant_id", "program_id"), + Index("ix_wallet_accounts_member", "tenant_id", "member_id"), + Index("ix_wallet_accounts_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class WalletLedgerEntry(UUIDPrimaryKeyMixin, TenantMixin, Base): + """Append-only wallet ledger entry. Balance = SUM(signed amounts). Never update/delete.""" + + __tablename__ = "wallet_ledger_entries" + + wallet_account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + entry_type: Mapped[WalletLedgerEntryType] = mapped_column(nullable=False) + # Signed minor-unit amount: credit/transfer_in positive; debit/transfer_out + # negative; adjust caller-supplied signed value. + amount: Mapped[int] = mapped_column(Integer, nullable=False) + balance_after: Mapped[int] = mapped_column(Integer, nullable=False) + idempotency_key: Mapped[str | None] = mapped_column(String(100), nullable=True) + reference_type: Mapped[str | None] = mapped_column(String(50), nullable=True) + reference_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + 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__ = ( + UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_wallet_ledger_tenant_idempotency", + ), + Index("ix_wallet_ledger_account", "tenant_id", "wallet_account_id"), + Index("ix_wallet_ledger_member", "tenant_id", "member_id"), + Index("ix_wallet_ledger_type", "tenant_id", "entry_type"), + Index("ix_wallet_ledger_created", "tenant_id", "created_at"), + ) diff --git a/backend/services/loyalty/app/permissions/definitions.py b/backend/services/loyalty/app/permissions/definitions.py index 374ad80..7526c4b 100644 --- a/backend/services/loyalty/app/permissions/definitions.py +++ b/backend/services/loyalty/app/permissions/definitions.py @@ -36,17 +36,64 @@ LOYALTY_POINT_ACCOUNTS_CREATE = "loyalty.point_accounts.create" LOYALTY_POINT_ACCOUNTS_UPDATE = "loyalty.point_accounts.update" LOYALTY_POINT_ACCOUNTS_MANAGE = "loyalty.point_accounts.manage" +LOYALTY_POINTS_VIEW = "loyalty.points.view" +LOYALTY_POINTS_EARN = "loyalty.points.earn" +LOYALTY_POINTS_REDEEM = "loyalty.points.redeem" +LOYALTY_POINTS_ADJUST = "loyalty.points.adjust" +LOYALTY_POINTS_EXPIRE = "loyalty.points.expire" +LOYALTY_POINTS_MANAGE = "loyalty.points.manage" + LOYALTY_REWARDS_VIEW = "loyalty.rewards.view" LOYALTY_REWARDS_CREATE = "loyalty.rewards.create" LOYALTY_REWARDS_UPDATE = "loyalty.rewards.update" LOYALTY_REWARDS_DELETE = "loyalty.rewards.delete" LOYALTY_REWARDS_MANAGE = "loyalty.rewards.manage" +LOYALTY_REWARDS_REDEEM = "loyalty.rewards.redeem" +LOYALTY_REWARDS_FULFILL = "loyalty.rewards.fulfill" +LOYALTY_REWARDS_CANCEL = "loyalty.rewards.cancel" + +LOYALTY_REDEMPTIONS_VIEW = "loyalty.redemptions.view" LOYALTY_CAMPAIGNS_VIEW = "loyalty.campaigns.view" LOYALTY_CAMPAIGNS_CREATE = "loyalty.campaigns.create" LOYALTY_CAMPAIGNS_UPDATE = "loyalty.campaigns.update" LOYALTY_CAMPAIGNS_DELETE = "loyalty.campaigns.delete" LOYALTY_CAMPAIGNS_MANAGE = "loyalty.campaigns.manage" +LOYALTY_CAMPAIGNS_SCHEDULE = "loyalty.campaigns.schedule" +LOYALTY_CAMPAIGNS_ACTIVATE = "loyalty.campaigns.activate" +LOYALTY_CAMPAIGNS_PAUSE = "loyalty.campaigns.pause" +LOYALTY_CAMPAIGNS_RESUME = "loyalty.campaigns.resume" +LOYALTY_CAMPAIGNS_EXPIRE = "loyalty.campaigns.expire" +LOYALTY_CAMPAIGNS_APPLY = "loyalty.campaigns.apply" +LOYALTY_CAMPAIGNS_RULES_UPDATE = "loyalty.campaigns.rules.update" +LOYALTY_CAMPAIGNS_APPLICATIONS_VIEW = "loyalty.campaigns.applications.view" + +LOYALTY_REFERRAL_PROGRAMS_VIEW = "loyalty.referral_programs.view" +LOYALTY_REFERRAL_PROGRAMS_CREATE = "loyalty.referral_programs.create" +LOYALTY_REFERRAL_PROGRAMS_UPDATE = "loyalty.referral_programs.update" +LOYALTY_REFERRAL_PROGRAMS_DELETE = "loyalty.referral_programs.delete" +LOYALTY_REFERRAL_PROGRAMS_MANAGE = "loyalty.referral_programs.manage" + +LOYALTY_REFERRAL_CODES_VIEW = "loyalty.referral_codes.view" +LOYALTY_REFERRAL_CODES_CREATE = "loyalty.referral_codes.create" +LOYALTY_REFERRAL_CODES_REVOKE = "loyalty.referral_codes.revoke" +LOYALTY_REFERRAL_CODES_MANAGE = "loyalty.referral_codes.manage" + +LOYALTY_REFERRALS_VIEW = "loyalty.referrals.view" +LOYALTY_REFERRALS_ATTRIBUTE = "loyalty.referrals.attribute" +LOYALTY_REFERRALS_CONVERT = "loyalty.referrals.convert" +LOYALTY_REFERRALS_REWARD = "loyalty.referrals.reward" +LOYALTY_REFERRALS_CANCEL = "loyalty.referrals.cancel" +LOYALTY_REFERRALS_MANAGE = "loyalty.referrals.manage" + +LOYALTY_WALLETS_VIEW = "loyalty.wallets.view" +LOYALTY_WALLETS_CREATE = "loyalty.wallets.create" +LOYALTY_WALLETS_CREDIT = "loyalty.wallets.credit" +LOYALTY_WALLETS_DEBIT = "loyalty.wallets.debit" +LOYALTY_WALLETS_ADJUST = "loyalty.wallets.adjust" +LOYALTY_WALLETS_TRANSFER = "loyalty.wallets.transfer" +LOYALTY_WALLETS_DELETE = "loyalty.wallets.delete" +LOYALTY_WALLETS_MANAGE = "loyalty.wallets.manage" LOYALTY_AUDIT_VIEW = "loyalty.audit.view" @@ -81,16 +128,57 @@ ALL_PERMISSIONS: list[str] = [ LOYALTY_POINT_ACCOUNTS_CREATE, LOYALTY_POINT_ACCOUNTS_UPDATE, LOYALTY_POINT_ACCOUNTS_MANAGE, + LOYALTY_POINTS_VIEW, + LOYALTY_POINTS_EARN, + LOYALTY_POINTS_REDEEM, + LOYALTY_POINTS_ADJUST, + LOYALTY_POINTS_EXPIRE, + LOYALTY_POINTS_MANAGE, LOYALTY_REWARDS_VIEW, LOYALTY_REWARDS_CREATE, LOYALTY_REWARDS_UPDATE, LOYALTY_REWARDS_DELETE, LOYALTY_REWARDS_MANAGE, + LOYALTY_REWARDS_REDEEM, + LOYALTY_REWARDS_FULFILL, + LOYALTY_REWARDS_CANCEL, + LOYALTY_REDEMPTIONS_VIEW, LOYALTY_CAMPAIGNS_VIEW, LOYALTY_CAMPAIGNS_CREATE, LOYALTY_CAMPAIGNS_UPDATE, LOYALTY_CAMPAIGNS_DELETE, LOYALTY_CAMPAIGNS_MANAGE, + LOYALTY_CAMPAIGNS_SCHEDULE, + LOYALTY_CAMPAIGNS_ACTIVATE, + LOYALTY_CAMPAIGNS_PAUSE, + LOYALTY_CAMPAIGNS_RESUME, + LOYALTY_CAMPAIGNS_EXPIRE, + LOYALTY_CAMPAIGNS_APPLY, + LOYALTY_CAMPAIGNS_RULES_UPDATE, + LOYALTY_CAMPAIGNS_APPLICATIONS_VIEW, + LOYALTY_REFERRAL_PROGRAMS_VIEW, + LOYALTY_REFERRAL_PROGRAMS_CREATE, + LOYALTY_REFERRAL_PROGRAMS_UPDATE, + LOYALTY_REFERRAL_PROGRAMS_DELETE, + LOYALTY_REFERRAL_PROGRAMS_MANAGE, + LOYALTY_REFERRAL_CODES_VIEW, + LOYALTY_REFERRAL_CODES_CREATE, + LOYALTY_REFERRAL_CODES_REVOKE, + LOYALTY_REFERRAL_CODES_MANAGE, + LOYALTY_REFERRALS_VIEW, + LOYALTY_REFERRALS_ATTRIBUTE, + LOYALTY_REFERRALS_CONVERT, + LOYALTY_REFERRALS_REWARD, + LOYALTY_REFERRALS_CANCEL, + LOYALTY_REFERRALS_MANAGE, + LOYALTY_WALLETS_VIEW, + LOYALTY_WALLETS_CREATE, + LOYALTY_WALLETS_CREDIT, + LOYALTY_WALLETS_DEBIT, + LOYALTY_WALLETS_ADJUST, + LOYALTY_WALLETS_TRANSFER, + LOYALTY_WALLETS_DELETE, + LOYALTY_WALLETS_MANAGE, LOYALTY_AUDIT_VIEW, ] @@ -100,7 +188,13 @@ PERMISSION_PREFIXES: tuple[str, ...] = ( "loyalty.tiers.", "loyalty.members.", "loyalty.point_accounts.", + "loyalty.points.", "loyalty.rewards.", + "loyalty.redemptions.", "loyalty.campaigns.", + "loyalty.referral_programs.", + "loyalty.referral_codes.", + "loyalty.referrals.", + "loyalty.wallets.", "loyalty.audit.", ) diff --git a/backend/services/loyalty/app/repositories/campaigns.py b/backend/services/loyalty/app/repositories/campaigns.py new file mode 100644 index 0000000..9073c2c --- /dev/null +++ b/backend/services/loyalty/app/repositories/campaigns.py @@ -0,0 +1,77 @@ +"""Campaign application repositories — Phase 7.4.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import func, select + +from app.models.campaigns import CampaignApplication +from app.repositories.base import TenantBaseRepository + + +class CampaignApplicationRepository(TenantBaseRepository[CampaignApplication]): + model = CampaignApplication + + async def get_by_idempotency( + self, tenant_id: UUID, idempotency_key: str | None + ) -> CampaignApplication | None: + if not idempotency_key: + return None + stmt = select(CampaignApplication).where( + CampaignApplication.tenant_id == tenant_id, + CampaignApplication.idempotency_key == idempotency_key, + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def count_for_member( + self, + tenant_id: UUID, + campaign_id: UUID, + member_id: UUID, + *, + exclude_statuses: tuple = (), + ) -> int: + clauses = [ + CampaignApplication.tenant_id == tenant_id, + CampaignApplication.campaign_id == campaign_id, + CampaignApplication.member_id == member_id, + ] + if exclude_statuses: + clauses.append(CampaignApplication.status.notin_(exclude_statuses)) + stmt = select(func.count()).select_from(CampaignApplication).where(*clauses) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + async def list_for_campaign( + self, + tenant_id: UUID, + campaign_id: UUID, + *, + offset: int = 0, + limit: int = 50, + ): + stmt = ( + select(CampaignApplication) + .where( + CampaignApplication.tenant_id == tenant_id, + CampaignApplication.campaign_id == campaign_id, + ) + .order_by(CampaignApplication.created_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def count_for_campaign(self, tenant_id: UUID, campaign_id: UUID) -> int: + stmt = ( + select(func.count()) + .select_from(CampaignApplication) + .where( + CampaignApplication.tenant_id == tenant_id, + CampaignApplication.campaign_id == campaign_id, + ) + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) diff --git a/backend/services/loyalty/app/repositories/ledger.py b/backend/services/loyalty/app/repositories/ledger.py new file mode 100644 index 0000000..c070aea --- /dev/null +++ b/backend/services/loyalty/app/repositories/ledger.py @@ -0,0 +1,92 @@ +"""Point ledger repositories — Phase 7.2.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.ledger import PointLedgerEntry +from app.models.types import LedgerEntryType +from app.repositories.base import TenantBaseRepository + + +class PointLedgerRepository(TenantBaseRepository[PointLedgerEntry]): + model = PointLedgerEntry + + async def get_by_idempotency( + self, tenant_id: UUID, idempotency_key: str + ) -> PointLedgerEntry | None: + if not idempotency_key: + return None + stmt = select(PointLedgerEntry).where( + PointLedgerEntry.tenant_id == tenant_id, + PointLedgerEntry.idempotency_key == idempotency_key, + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def balance_for_account(self, tenant_id: UUID, point_account_id: UUID) -> int: + stmt = select(func.coalesce(func.sum(PointLedgerEntry.amount), 0)).where( + PointLedgerEntry.tenant_id == tenant_id, + PointLedgerEntry.point_account_id == point_account_id, + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + async def list_for_account( + self, + tenant_id: UUID, + point_account_id: UUID, + *, + entry_type: LedgerEntryType | None = None, + offset: int = 0, + limit: int = 50, + q: str | None = None, + ): + clauses = [ + PointLedgerEntry.tenant_id == tenant_id, + PointLedgerEntry.point_account_id == point_account_id, + ] + if entry_type is not None: + clauses.append(PointLedgerEntry.entry_type == entry_type) + if q: + like = f"%{q.strip()}%" + clauses.append( + (PointLedgerEntry.reason.ilike(like)) + | (PointLedgerEntry.reference_id.ilike(like)) + | (PointLedgerEntry.idempotency_key.ilike(like)) + ) + stmt = ( + select(PointLedgerEntry) + .where(*clauses) + .order_by(PointLedgerEntry.created_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def count_for_account( + self, + tenant_id: UUID, + point_account_id: UUID, + *, + entry_type: LedgerEntryType | None = None, + q: str | None = None, + ) -> int: + clauses = [ + PointLedgerEntry.tenant_id == tenant_id, + PointLedgerEntry.point_account_id == point_account_id, + ] + if entry_type is not None: + clauses.append(PointLedgerEntry.entry_type == entry_type) + if q: + like = f"%{q.strip()}%" + clauses.append( + (PointLedgerEntry.reason.ilike(like)) + | (PointLedgerEntry.reference_id.ilike(like)) + ) + stmt = select(func.count()).select_from(PointLedgerEntry).where(*clauses) + result = await self.session.execute(stmt) + return int(result.scalar_one()) diff --git a/backend/services/loyalty/app/repositories/referral.py b/backend/services/loyalty/app/repositories/referral.py new file mode 100644 index 0000000..5a8f0aa --- /dev/null +++ b/backend/services/loyalty/app/repositories/referral.py @@ -0,0 +1,162 @@ +"""Referral Engine repositories — Phase 7.5.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import func, select + +from app.models.referral import ReferralAttribution, ReferralCode, ReferralProgram +from app.models.types import ReferralCodeStatus +from app.repositories.base import TenantBaseRepository + + +class ReferralProgramRepository(TenantBaseRepository[ReferralProgram]): + model = ReferralProgram + + async def get_by_code( + self, tenant_id: UUID, program_id: UUID, code: str + ) -> ReferralProgram | None: + stmt = select(ReferralProgram).where( + ReferralProgram.tenant_id == tenant_id, + ReferralProgram.program_id == program_id, + ReferralProgram.code == code, + ReferralProgram.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class ReferralCodeRepository(TenantBaseRepository[ReferralCode]): + model = ReferralCode + + async def get_by_code(self, tenant_id: UUID, code: str) -> ReferralCode | None: + stmt = select(ReferralCode).where( + ReferralCode.tenant_id == tenant_id, + ReferralCode.code == code, + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_active_for_member( + self, tenant_id: UUID, referral_program_id: UUID, member_id: UUID + ) -> ReferralCode | None: + stmt = select(ReferralCode).where( + ReferralCode.tenant_id == tenant_id, + ReferralCode.referral_program_id == referral_program_id, + ReferralCode.member_id == member_id, + ReferralCode.status == ReferralCodeStatus.ACTIVE, + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_for_program( + self, + tenant_id: UUID, + referral_program_id: UUID | None = None, + *, + offset: int = 0, + limit: int = 50, + ): + clauses = [ReferralCode.tenant_id == tenant_id] + if referral_program_id is not None: + clauses.append(ReferralCode.referral_program_id == referral_program_id) + stmt = ( + select(ReferralCode) + .where(*clauses) + .order_by(ReferralCode.created_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def count_for_program( + self, tenant_id: UUID, referral_program_id: UUID | None = None + ) -> int: + clauses = [ReferralCode.tenant_id == tenant_id] + if referral_program_id is not None: + clauses.append(ReferralCode.referral_program_id == referral_program_id) + stmt = select(func.count()).select_from(ReferralCode).where(*clauses) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + +class ReferralAttributionRepository(TenantBaseRepository[ReferralAttribution]): + model = ReferralAttribution + + async def get_by_idempotency( + self, tenant_id: UUID, idempotency_key: str | None + ) -> ReferralAttribution | None: + if not idempotency_key: + return None + stmt = select(ReferralAttribution).where( + ReferralAttribution.tenant_id == tenant_id, + ReferralAttribution.idempotency_key == idempotency_key, + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_referee( + self, tenant_id: UUID, referral_program_id: UUID, referee_member_id: UUID + ) -> ReferralAttribution | None: + stmt = select(ReferralAttribution).where( + ReferralAttribution.tenant_id == tenant_id, + ReferralAttribution.referral_program_id == referral_program_id, + ReferralAttribution.referee_member_id == referee_member_id, + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def count_for_referrer( + self, + tenant_id: UUID, + referral_program_id: UUID, + referrer_member_id: UUID, + *, + exclude_statuses: tuple = (), + ) -> int: + clauses = [ + ReferralAttribution.tenant_id == tenant_id, + ReferralAttribution.referral_program_id == referral_program_id, + ReferralAttribution.referrer_member_id == referrer_member_id, + ] + if exclude_statuses: + clauses.append(ReferralAttribution.status.notin_(exclude_statuses)) + stmt = select(func.count()).select_from(ReferralAttribution).where(*clauses) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + async def list_for_program( + self, + tenant_id: UUID, + referral_program_id: UUID | None = None, + *, + offset: int = 0, + limit: int = 50, + ): + clauses = [ReferralAttribution.tenant_id == tenant_id] + if referral_program_id is not None: + clauses.append( + ReferralAttribution.referral_program_id == referral_program_id + ) + stmt = ( + select(ReferralAttribution) + .where(*clauses) + .order_by(ReferralAttribution.created_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def count_for_program( + self, tenant_id: UUID, referral_program_id: UUID | None = None + ) -> int: + clauses = [ReferralAttribution.tenant_id == tenant_id] + if referral_program_id is not None: + clauses.append( + ReferralAttribution.referral_program_id == referral_program_id + ) + stmt = select(func.count()).select_from(ReferralAttribution).where(*clauses) + result = await self.session.execute(stmt) + return int(result.scalar_one()) diff --git a/backend/services/loyalty/app/repositories/rewards.py b/backend/services/loyalty/app/repositories/rewards.py new file mode 100644 index 0000000..435bd09 --- /dev/null +++ b/backend/services/loyalty/app/repositories/rewards.py @@ -0,0 +1,105 @@ +"""Reward redemption repositories — Phase 7.3.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import func, select + +from app.models.rewards import RewardRedemption +from app.models.types import RedemptionStatus +from app.repositories.base import TenantBaseRepository + +_NON_BLOCKING_STATUSES = (RedemptionStatus.CANCELLED,) + + +class RewardRedemptionRepository(TenantBaseRepository[RewardRedemption]): + model = RewardRedemption + + async def get_by_idempotency( + self, tenant_id: UUID, idempotency_key: str | None + ) -> RewardRedemption | None: + if not idempotency_key: + return None + stmt = select(RewardRedemption).where( + RewardRedemption.tenant_id == tenant_id, + RewardRedemption.idempotency_key == idempotency_key, + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def count_for_member_reward( + self, + tenant_id: UUID, + member_id: UUID, + reward_id: UUID, + *, + exclude_statuses: tuple[RedemptionStatus, ...] = _NON_BLOCKING_STATUSES, + ) -> int: + stmt = ( + select(func.count()) + .select_from(RewardRedemption) + .where( + RewardRedemption.tenant_id == tenant_id, + RewardRedemption.member_id == member_id, + RewardRedemption.reward_id == reward_id, + RewardRedemption.status.notin_(exclude_statuses), + ) + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + async def list( + self, + tenant_id: UUID, + *, + reward_id: UUID | None = None, + member_id: UUID | None = None, + status: RedemptionStatus | None = None, + offset: int = 0, + limit: int = 50, + ): + clauses = [RewardRedemption.tenant_id == tenant_id] + if reward_id is not None: + clauses.append(RewardRedemption.reward_id == reward_id) + if member_id is not None: + clauses.append(RewardRedemption.member_id == member_id) + if status is not None: + clauses.append(RewardRedemption.status == status) + stmt = ( + select(RewardRedemption) + .where(*clauses) + .order_by(RewardRedemption.created_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def count( + self, + tenant_id: UUID, + *, + reward_id: UUID | None = None, + member_id: UUID | None = None, + status: RedemptionStatus | None = None, + ) -> int: + clauses = [RewardRedemption.tenant_id == tenant_id] + if reward_id is not None: + clauses.append(RewardRedemption.reward_id == reward_id) + if member_id is not None: + clauses.append(RewardRedemption.member_id == member_id) + if status is not None: + clauses.append(RewardRedemption.status == status) + stmt = select(func.count()).select_from(RewardRedemption).where(*clauses) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + async def list_for_member( + self, tenant_id: UUID, member_id: UUID, *, offset: int = 0, limit: int = 50 + ): + return await self.list(tenant_id, member_id=member_id, offset=offset, limit=limit) + + async def list_for_reward( + self, tenant_id: UUID, reward_id: UUID, *, offset: int = 0, limit: int = 50 + ): + return await self.list(tenant_id, reward_id=reward_id, offset=offset, limit=limit) diff --git a/backend/services/loyalty/app/repositories/wallet.py b/backend/services/loyalty/app/repositories/wallet.py new file mode 100644 index 0000000..945ebe3 --- /dev/null +++ b/backend/services/loyalty/app/repositories/wallet.py @@ -0,0 +1,105 @@ +"""Wallet Engine repositories — Phase 7.6.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import func, select + +from app.models.types import WalletLedgerEntryType +from app.models.wallet import WalletAccount, WalletLedgerEntry +from app.repositories.base import TenantBaseRepository + + +class WalletAccountRepository(TenantBaseRepository[WalletAccount]): + model = WalletAccount + + async def get_by_member_program( + self, tenant_id: UUID, program_id: UUID, member_id: UUID + ) -> WalletAccount | None: + stmt = select(WalletAccount).where( + WalletAccount.tenant_id == tenant_id, + WalletAccount.program_id == program_id, + WalletAccount.member_id == member_id, + WalletAccount.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_account_number( + self, tenant_id: UUID, account_number: str + ) -> WalletAccount | None: + stmt = select(WalletAccount).where( + WalletAccount.tenant_id == tenant_id, + WalletAccount.account_number == account_number, + WalletAccount.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class WalletLedgerRepository(TenantBaseRepository[WalletLedgerEntry]): + model = WalletLedgerEntry + + async def balance_for_account( + self, tenant_id: UUID, wallet_account_id: UUID + ) -> int: + stmt = select(func.coalesce(func.sum(WalletLedgerEntry.amount), 0)).where( + WalletLedgerEntry.tenant_id == tenant_id, + WalletLedgerEntry.wallet_account_id == wallet_account_id, + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + async def get_by_idempotency( + self, tenant_id: UUID, idempotency_key: str | None + ) -> WalletLedgerEntry | None: + if not idempotency_key: + return None + stmt = select(WalletLedgerEntry).where( + WalletLedgerEntry.tenant_id == tenant_id, + WalletLedgerEntry.idempotency_key == idempotency_key, + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_for_account( + self, + tenant_id: UUID, + wallet_account_id: UUID, + *, + entry_type: WalletLedgerEntryType | None = None, + offset: int = 0, + limit: int = 50, + ): + clauses = [ + WalletLedgerEntry.tenant_id == tenant_id, + WalletLedgerEntry.wallet_account_id == wallet_account_id, + ] + if entry_type is not None: + clauses.append(WalletLedgerEntry.entry_type == entry_type) + stmt = ( + select(WalletLedgerEntry) + .where(*clauses) + .order_by(WalletLedgerEntry.created_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def count_for_account( + self, + tenant_id: UUID, + wallet_account_id: UUID, + *, + entry_type: WalletLedgerEntryType | None = None, + ) -> int: + clauses = [ + WalletLedgerEntry.tenant_id == tenant_id, + WalletLedgerEntry.wallet_account_id == wallet_account_id, + ] + if entry_type is not None: + clauses.append(WalletLedgerEntry.entry_type == entry_type) + stmt = select(func.count()).select_from(WalletLedgerEntry).where(*clauses) + result = await self.session.execute(stmt) + return int(result.scalar_one()) diff --git a/backend/services/loyalty/app/schemas/campaigns.py b/backend/services/loyalty/app/schemas/campaigns.py new file mode 100644 index 0000000..36ea0d4 --- /dev/null +++ b/backend/services/loyalty/app/schemas/campaigns.py @@ -0,0 +1,52 @@ +"""Campaign Engine DTOs — Phase 7.4.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import CampaignApplicationStatus +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class CampaignRulesUpdateRequest(BaseModel): + model_config = _FORBID + + rules: dict = Field(default_factory=dict) + + +class ApplyCampaignRequest(BaseModel): + model_config = _FORBID + + member_id: UUID + point_account_id: UUID | None = None + base_amount: int | None = Field(default=None, ge=0) + idempotency_key: str | None = Field(default=None, max_length=100) + metadata_json: dict | None = None + + +class CampaignApplicationRead(ORMBase): + id: UUID + tenant_id: UUID + campaign_id: UUID + program_id: UUID + member_id: UUID + point_account_id: UUID + rule_version: int + points_granted: int + ledger_entry_id: UUID | None + status: CampaignApplicationStatus + idempotency_key: str | None + actor_user_id: str | None + metadata_json: dict | None + created_at: datetime + + +class CampaignApplicationPage(BaseModel): + items: list[CampaignApplicationRead] + total: int + offset: int + limit: int diff --git a/backend/services/loyalty/app/schemas/foundation.py b/backend/services/loyalty/app/schemas/foundation.py index cd3dec4..5b02aaf 100644 --- a/backend/services/loyalty/app/schemas/foundation.py +++ b/backend/services/loyalty/app/schemas/foundation.py @@ -285,6 +285,8 @@ class RewardCreate(BaseModel): points_cost: int = Field(default=0, ge=0) eligibility_rules: dict | None = None metadata_json: dict | None = None + stock_limit: int | None = Field(default=None, ge=0) + max_per_member: int | None = Field(default=None, ge=0) class RewardUpdate(BaseModel): @@ -297,6 +299,8 @@ class RewardUpdate(BaseModel): points_cost: int | None = Field(default=None, ge=0) eligibility_rules: dict | None = None metadata_json: dict | None = None + stock_limit: int | None = Field(default=None, ge=0) + max_per_member: int | None = Field(default=None, ge=0) class RewardRead(ORMBase): @@ -311,6 +315,9 @@ class RewardRead(ORMBase): points_cost: int eligibility_rules: dict | None metadata_json: dict | None + stock_limit: int | None + redeemed_count: int + max_per_member: int | None is_deleted: bool created_by: str | None updated_by: str | None diff --git a/backend/services/loyalty/app/schemas/ledger.py b/backend/services/loyalty/app/schemas/ledger.py new file mode 100644 index 0000000..f8582f1 --- /dev/null +++ b/backend/services/loyalty/app/schemas/ledger.py @@ -0,0 +1,92 @@ +"""Point ledger DTOs — Phase 7.2.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import LedgerEntryType, PointAccountStatus +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class PointEarnRequest(BaseModel): + model_config = _FORBID + + amount: int = Field(gt=0) + reason: str | None = Field(default=None, max_length=500) + idempotency_key: str | None = Field(default=None, max_length=100) + reference_type: str | None = Field(default=None, max_length=50) + reference_id: str | None = Field(default=None, max_length=100) + expires_at: datetime | None = None + metadata_json: dict | None = None + + +class PointRedeemRequest(BaseModel): + model_config = _FORBID + + amount: int = Field(gt=0) + reason: str | None = Field(default=None, max_length=500) + idempotency_key: str | None = Field(default=None, max_length=100) + reference_type: str | None = Field(default=None, max_length=50) + reference_id: str | None = Field(default=None, max_length=100) + metadata_json: dict | None = None + + +class PointAdjustRequest(BaseModel): + model_config = _FORBID + + amount: int # signed + reason: str = Field(max_length=500) + idempotency_key: str | None = Field(default=None, max_length=100) + reference_type: str | None = Field(default=None, max_length=50) + reference_id: str | None = Field(default=None, max_length=100) + metadata_json: dict | None = None + + +class PointExpireRequest(BaseModel): + model_config = _FORBID + + amount: int = Field(gt=0) + reason: str | None = Field(default=None, max_length=500) + idempotency_key: str | None = Field(default=None, max_length=100) + reference_type: str | None = Field(default=None, max_length=50) + reference_id: str | None = Field(default=None, max_length=100) + metadata_json: dict | None = None + + +class PointLedgerEntryRead(ORMBase): + id: UUID + tenant_id: UUID + point_account_id: UUID + program_id: UUID + member_id: UUID + entry_type: LedgerEntryType + amount: int + balance_after: int + idempotency_key: str | None + reference_type: str | None + reference_id: str | None + reason: str | None + metadata_json: dict | None + expires_at: datetime | None + actor_user_id: str | None + created_at: datetime + + +class PointBalanceRead(BaseModel): + point_account_id: UUID + member_id: UUID + program_id: UUID + status: PointAccountStatus + balance: int + currency_name: str + + +class PointLedgerPage(BaseModel): + items: list[PointLedgerEntryRead] + total: int + offset: int + limit: int diff --git a/backend/services/loyalty/app/schemas/referral.py b/backend/services/loyalty/app/schemas/referral.py new file mode 100644 index 0000000..7645662 --- /dev/null +++ b/backend/services/loyalty/app/schemas/referral.py @@ -0,0 +1,133 @@ +"""Referral Engine DTOs — Phase 7.5.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import ( + ReferralAttributionStatus, + ReferralCodeStatus, + ReferralProgramStatus, +) +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class ReferralProgramCreate(BaseModel): + model_config = _FORBID + + program_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + status: ReferralProgramStatus = ReferralProgramStatus.DRAFT + referrer_bonus_points: int = Field(default=0, ge=0) + referee_bonus_points: int = Field(default=0, ge=0) + max_referrals_per_member: int | None = Field(default=None, ge=1) + + +class ReferralProgramUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + status: ReferralProgramStatus | None = None + referrer_bonus_points: int | None = Field(default=None, ge=0) + referee_bonus_points: int | None = Field(default=None, ge=0) + max_referrals_per_member: int | None = Field(default=None, ge=1) + + +class ReferralProgramRead(ORMBase): + id: UUID + tenant_id: UUID + program_id: UUID + code: str + name: str + status: ReferralProgramStatus + referrer_bonus_points: int + referee_bonus_points: int + max_referrals_per_member: int | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ReferralProgramPage(BaseModel): + items: list[ReferralProgramRead] + total: int + offset: int + limit: int + + +class ReferralCodeIssueRequest(BaseModel): + model_config = _FORBID + + referral_program_id: UUID + member_id: UUID + code: str | None = Field(default=None, max_length=50) + + +class ReferralCodeRead(ORMBase): + id: UUID + tenant_id: UUID + referral_program_id: UUID + program_id: UUID + member_id: UUID + code: str + status: ReferralCodeStatus + uses_count: int + created_by: str | None + created_at: datetime + updated_at: datetime + + +class ReferralCodePage(BaseModel): + items: list[ReferralCodeRead] + total: int + offset: int + limit: int + + +class ReferralAttributeRequest(BaseModel): + model_config = _FORBID + + code: str = Field(max_length=50) + referee_member_id: UUID + idempotency_key: str | None = Field(default=None, max_length=100) + + +class ReferralCancelRequest(BaseModel): + model_config = _FORBID + + reason: str | None = Field(default=None, max_length=500) + + +class ReferralAttributionRead(ORMBase): + id: UUID + tenant_id: UUID + referral_program_id: UUID + referral_code_id: UUID + program_id: UUID + referrer_member_id: UUID + referee_member_id: UUID + status: ReferralAttributionStatus + referrer_ledger_entry_id: UUID | None + referee_ledger_entry_id: UUID | None + idempotency_key: str | None + converted_at: datetime | None + rewarded_at: datetime | None + cancelled_at: datetime | None + cancel_reason: str | None + actor_user_id: str | None + created_at: datetime + updated_at: datetime + + +class ReferralAttributionPage(BaseModel): + items: list[ReferralAttributionRead] + total: int + offset: int + limit: int diff --git a/backend/services/loyalty/app/schemas/rewards.py b/backend/services/loyalty/app/schemas/rewards.py new file mode 100644 index 0000000..a1f2457 --- /dev/null +++ b/backend/services/loyalty/app/schemas/rewards.py @@ -0,0 +1,56 @@ +"""Reward redemption DTOs — Phase 7.3.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import RedemptionStatus +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class RedeemRequest(BaseModel): + model_config = _FORBID + + member_id: UUID + idempotency_key: str | None = Field(default=None, max_length=100) + reason: str | None = Field(default=None, max_length=500) + metadata_json: dict | None = None + + +class CancelRequest(BaseModel): + model_config = _FORBID + + reason: str = Field(max_length=500) + + +class RewardRedemptionRead(ORMBase): + id: UUID + tenant_id: UUID + program_id: UUID + reward_id: UUID + member_id: UUID + point_account_id: UUID + status: RedemptionStatus + points_spent: int + ledger_entry_id: UUID | None + voucher_code: str + idempotency_key: str | None + reason: str | None + cancel_reason: str | None + fulfilled_at: datetime | None + cancelled_at: datetime | None + metadata_json: dict | None + actor_user_id: str | None + created_at: datetime + updated_at: datetime + + +class RewardRedemptionPage(BaseModel): + items: list[RewardRedemptionRead] + total: int + offset: int + limit: int diff --git a/backend/services/loyalty/app/schemas/wallet.py b/backend/services/loyalty/app/schemas/wallet.py new file mode 100644 index 0000000..d9f3f77 --- /dev/null +++ b/backend/services/loyalty/app/schemas/wallet.py @@ -0,0 +1,125 @@ +"""Wallet Engine DTOs — Phase 7.6.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import WalletAccountStatus, WalletLedgerEntryType +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class WalletAccountCreate(BaseModel): + model_config = _FORBID + + program_id: UUID + member_id: UUID + account_number: str | None = Field(default=None, max_length=50) + currency_code: str = Field(default="IRR", max_length=3) + status: WalletAccountStatus = WalletAccountStatus.OPEN + + +class WalletAccountUpdate(BaseModel): + model_config = _FORBID + + status: WalletAccountStatus | None = None + + +class WalletAccountRead(ORMBase): + id: UUID + tenant_id: UUID + program_id: UUID + member_id: UUID + account_number: str + currency_code: str + status: WalletAccountStatus + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class WalletCreditRequest(BaseModel): + model_config = _FORBID + + amount: int = Field(gt=0) + reason: str | None = Field(default=None, max_length=500) + idempotency_key: str | None = Field(default=None, max_length=100) + reference_type: str | None = Field(default=None, max_length=50) + reference_id: str | None = Field(default=None, max_length=100) + metadata: dict | None = None + + +class WalletDebitRequest(BaseModel): + model_config = _FORBID + + amount: int = Field(gt=0) + reason: str | None = Field(default=None, max_length=500) + idempotency_key: str | None = Field(default=None, max_length=100) + reference_type: str | None = Field(default=None, max_length=50) + reference_id: str | None = Field(default=None, max_length=100) + metadata: dict | None = None + + +class WalletAdjustRequest(BaseModel): + model_config = _FORBID + + amount: int # signed + reason: str = Field(max_length=500) + idempotency_key: str | None = Field(default=None, max_length=100) + reference_type: str | None = Field(default=None, max_length=50) + reference_id: str | None = Field(default=None, max_length=100) + metadata: dict | None = None + + +class WalletTransferRequest(BaseModel): + model_config = _FORBID + + target_wallet_id: UUID + amount: int = Field(gt=0) + reason: str | None = Field(default=None, max_length=500) + idempotency_key: str | None = Field(default=None, max_length=100) + metadata: dict | None = None + + +class WalletLedgerEntryRead(ORMBase): + id: UUID + tenant_id: UUID + wallet_account_id: UUID + program_id: UUID + member_id: UUID + entry_type: WalletLedgerEntryType + amount: int + balance_after: int + idempotency_key: str | None + reference_type: str | None + reference_id: str | None + reason: str | None + metadata_json: dict | None + actor_user_id: str | None + created_at: datetime + + +class WalletBalanceRead(BaseModel): + wallet_account_id: UUID + member_id: UUID + program_id: UUID + status: WalletAccountStatus + currency_code: str + balance: int + + +class WalletLedgerPage(BaseModel): + items: list[WalletLedgerEntryRead] + total: int + offset: int + limit: int + + +class WalletTransferResult(BaseModel): + source_entry: WalletLedgerEntryRead + target_entry: WalletLedgerEntryRead diff --git a/backend/services/loyalty/app/services/campaign_engine.py b/backend/services/loyalty/app/services/campaign_engine.py new file mode 100644 index 0000000..74d37a4 --- /dev/null +++ b/backend/services/loyalty/app/services/campaign_engine.py @@ -0,0 +1,311 @@ +"""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 diff --git a/backend/services/loyalty/app/services/foundation.py b/backend/services/loyalty/app/services/foundation.py index 3d10e2d..edd090e 100644 --- a/backend/services/loyalty/app/services/foundation.py +++ b/backend/services/loyalty/app/services/foundation.py @@ -20,7 +20,13 @@ from app.models.foundation import ( PointAccount, Reward, ) -from app.models.types import AuditAction, MemberStatus, MembershipLifecycleAction, PointAccountStatus +from app.models.types import ( + AuditAction, + CampaignStatus, + MemberStatus, + MembershipLifecycleAction, + PointAccountStatus, +) from app.repositories.foundation import ( CampaignRepository, LoyaltyProgramRepository, @@ -992,6 +998,8 @@ class RewardService: points_cost=validate_non_negative_int(body.points_cost, field="points_cost"), eligibility_rules=body.eligibility_rules, metadata_json=body.metadata_json, + stock_limit=body.stock_limit, + max_per_member=body.max_per_member, created_by=actor_id, updated_by=actor_id, ) @@ -1237,6 +1245,12 @@ class CampaignService: actor: CurrentUser | None = None, ) -> Campaign: entity = await self.get(tenant_id, campaign_id) + if entity.status == CampaignStatus.ACTIVE: + raise AppError( + "کمپین فعال قابل حذف نیست", + status_code=409, + error_code="campaign_active_not_deletable", + ) entity.code = release_unique_token(entity.code, entity.id) await self.repo.soft_delete( entity, deleted_by=actor.user_id if actor else None diff --git a/backend/services/loyalty/app/services/point_engine.py b/backend/services/loyalty/app/services/point_engine.py new file mode 100644 index 0000000..758d2cb --- /dev/null +++ b/backend/services/loyalty/app/services/point_engine.py @@ -0,0 +1,291 @@ +"""Point Engine — immutable ledger writes (Phase 7.2).""" +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import TransactionalEventPublisher +from app.events.types import LoyaltyEventType +from app.models.ledger import PointLedgerEntry +from app.models.types import AuditAction, LedgerEntryType, PointAccountStatus +from app.repositories.foundation import PointAccountRepository +from app.repositories.ledger import PointLedgerRepository +from app.schemas.ledger import ( + PointAdjustRequest, + PointEarnRequest, + PointExpireRequest, + PointRedeemRequest, +) +from app.services.audit_service import AuditService +from app.validators.ledger import ( + ensure_account_open, + signed_amount, + validate_adjust_amount, + validate_positive_points, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class PointEngineService: + def __init__( + self, + session: AsyncSession, + publisher: TransactionalEventPublisher | None = None, + ) -> None: + self.session = session + self.accounts = PointAccountRepository(session) + self.ledger = PointLedgerRepository(session) + self.audit = AuditService(session) + self.publisher = publisher or TransactionalEventPublisher(session) + + async def _get_open_account(self, tenant_id: UUID, account_id: UUID): + entity = await self.accounts.get(tenant_id, account_id) + if entity is None: + raise NotFoundError( + "حساب امتیاز یافت نشد", error_code="point_account_not_found" + ) + ensure_account_open(entity.status) + return entity + + async def get_balance(self, tenant_id: UUID, account_id: UUID) -> dict: + entity = await self.accounts.get(tenant_id, account_id) + if entity is None: + raise NotFoundError( + "حساب امتیاز یافت نشد", error_code="point_account_not_found" + ) + balance = await self.ledger.balance_for_account(tenant_id, account_id) + return { + "point_account_id": account_id, + "member_id": entity.member_id, + "program_id": entity.program_id, + "status": entity.status, + "balance": balance, + "currency_name": entity.currency_name, + } + + async def list_entries( + self, + tenant_id: UUID, + account_id: UUID, + *, + entry_type: LedgerEntryType | None = None, + offset: int = 0, + limit: int = 50, + q: str | None = None, + ): + entity = await self.accounts.get(tenant_id, account_id) + if entity is None: + raise NotFoundError( + "حساب امتیاز یافت نشد", error_code="point_account_not_found" + ) + rows = await self.ledger.list_for_account( + tenant_id, + account_id, + entry_type=entry_type, + offset=offset, + limit=limit, + q=q, + ) + total = await self.ledger.count_for_account( + tenant_id, account_id, entry_type=entry_type, q=q + ) + return rows, total + + async def _append( + self, + *, + tenant_id: UUID, + account, + entry_type: LedgerEntryType, + signed: int, + body_idempotency: str | None, + reason: str | None, + reference_type: str | None, + reference_id: str | None, + expires_at: datetime | None, + metadata: dict | None, + actor: CurrentUser | None, + event_type: LoyaltyEventType, + audit_action: AuditAction, + auto_commit: bool = True, + ) -> PointLedgerEntry: + if body_idempotency: + existing = await self.ledger.get_by_idempotency(tenant_id, body_idempotency) + if existing is not None: + return existing + balance = await self.ledger.balance_for_account(tenant_id, account.id) + new_balance = balance + signed + if new_balance < 0: + raise AppError( + "موجودی امتیاز کافی نیست", + status_code=409, + error_code="insufficient_points", + details={"balance": balance, "requested": signed}, + ) + actor_id = actor.user_id if actor else None + entry = PointLedgerEntry( + tenant_id=tenant_id, + point_account_id=account.id, + program_id=account.program_id, + member_id=account.member_id, + entry_type=entry_type, + amount=signed, + balance_after=new_balance, + idempotency_key=body_idempotency, + reference_type=reference_type, + reference_id=reference_id, + reason=reason, + metadata_json=metadata, + expires_at=expires_at, + actor_user_id=actor_id, + created_at=_now(), + ) + await self.ledger.add(entry) + await self.audit.record( + tenant_id=tenant_id, + entity_type="point_ledger_entry", + entity_id=entry.id, + action=audit_action, + actor_user_id=actor_id, + message=f"{entry_type.value} {signed} on {account.account_number}", + changes={ + "amount": signed, + "balance_after": new_balance, + "point_account_id": str(account.id), + }, + ) + await self.publisher.publish( + event_type=event_type, + aggregate_type="point_ledger_entry", + aggregate_id=entry.id, + tenant_id=tenant_id, + payload={ + "point_account_id": str(account.id), + "entry_type": entry_type.value, + "amount": signed, + "balance_after": new_balance, + }, + ) + if auto_commit: + await self.session.commit() + await self.session.refresh(entry) + return entry + + async def earn( + self, + tenant_id: UUID, + account_id: UUID, + body: PointEarnRequest, + *, + actor: CurrentUser | None = None, + auto_commit: bool = True, + ) -> PointLedgerEntry: + account = await self._get_open_account(tenant_id, account_id) + amount = validate_positive_points(body.amount) + return await self._append( + tenant_id=tenant_id, + account=account, + entry_type=LedgerEntryType.EARN, + signed=signed_amount(LedgerEntryType.EARN, amount), + body_idempotency=body.idempotency_key, + reason=body.reason, + reference_type=body.reference_type, + reference_id=body.reference_id, + expires_at=body.expires_at, + metadata=body.metadata_json, + actor=actor, + event_type=LoyaltyEventType.POINTS_EARNED, + audit_action=AuditAction.EARN, + auto_commit=auto_commit, + ) + + async def redeem( + self, + tenant_id: UUID, + account_id: UUID, + body: PointRedeemRequest, + *, + actor: CurrentUser | None = None, + auto_commit: bool = True, + ) -> PointLedgerEntry: + account = await self._get_open_account(tenant_id, account_id) + amount = validate_positive_points(body.amount) + return await self._append( + tenant_id=tenant_id, + account=account, + entry_type=LedgerEntryType.REDEEM, + signed=signed_amount(LedgerEntryType.REDEEM, amount), + body_idempotency=body.idempotency_key, + reason=body.reason, + reference_type=body.reference_type, + reference_id=body.reference_id, + expires_at=None, + metadata=body.metadata_json, + actor=actor, + event_type=LoyaltyEventType.POINTS_REDEEMED, + audit_action=AuditAction.REDEEM, + auto_commit=auto_commit, + ) + + async def adjust( + self, + tenant_id: UUID, + account_id: UUID, + body: PointAdjustRequest, + *, + actor: CurrentUser | None = None, + auto_commit: bool = True, + ) -> PointLedgerEntry: + account = await self._get_open_account(tenant_id, account_id) + amount = validate_adjust_amount(body.amount) + return await self._append( + tenant_id=tenant_id, + account=account, + entry_type=LedgerEntryType.ADJUST, + signed=signed_amount(LedgerEntryType.ADJUST, amount), + body_idempotency=body.idempotency_key, + reason=body.reason, + reference_type=body.reference_type, + reference_id=body.reference_id, + expires_at=None, + metadata=body.metadata_json, + actor=actor, + event_type=LoyaltyEventType.POINTS_ADJUSTED, + audit_action=AuditAction.ADJUST, + auto_commit=auto_commit, + ) + + async def expire( + self, + tenant_id: UUID, + account_id: UUID, + body: PointExpireRequest, + *, + actor: CurrentUser | None = None, + auto_commit: bool = True, + ) -> PointLedgerEntry: + account = await self._get_open_account(tenant_id, account_id) + amount = validate_positive_points(body.amount) + return await self._append( + tenant_id=tenant_id, + account=account, + entry_type=LedgerEntryType.EXPIRE, + signed=signed_amount(LedgerEntryType.EXPIRE, amount), + body_idempotency=body.idempotency_key, + reason=body.reason or "points_expired", + reference_type=body.reference_type, + reference_id=body.reference_id, + expires_at=None, + metadata=body.metadata_json, + actor=actor, + event_type=LoyaltyEventType.POINTS_EXPIRED, + audit_action=AuditAction.POINTS_EXPIRE, + auto_commit=auto_commit, + ) diff --git a/backend/services/loyalty/app/services/referral_engine.py b/backend/services/loyalty/app/services/referral_engine.py new file mode 100644 index 0000000..94591fd --- /dev/null +++ b/backend/services/loyalty/app/services/referral_engine.py @@ -0,0 +1,672 @@ +"""Referral Engine — program CRUD, code issuance, and attribution lifecycle (Phase 7.5).""" +from __future__ import annotations + +from datetime import datetime, timezone +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.referral import ReferralAttribution, ReferralCode, ReferralProgram +from app.models.types import ( + AuditAction, + MemberStatus, + ReferralAttributionStatus, + ReferralCodeStatus, +) +from app.repositories.foundation import ( + LoyaltyProgramRepository, + MemberRepository, + PointAccountRepository, +) +from app.repositories.referral import ( + ReferralAttributionRepository, + ReferralCodeRepository, + ReferralProgramRepository, +) +from app.schemas.ledger import PointEarnRequest +from app.schemas.referral import ( + ReferralAttributeRequest, + ReferralCancelRequest, + ReferralCodeIssueRequest, + ReferralProgramCreate, + ReferralProgramUpdate, +) +from app.services.audit_service import AuditService +from app.services.point_engine import PointEngineService +from app.validators.foundation import ( + release_unique_token, + validate_code, + validate_non_empty, +) +from app.validators.referral import ( + ensure_attribution_cancellable, + ensure_attribution_converted, + ensure_attribution_pending, + ensure_code_active, + ensure_max_referrals_not_exceeded, + ensure_not_self_referral, + ensure_referral_program_active, + validate_referral_program_status, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _next_referral_code() -> str: + return f"REF-{uuid4().hex[:10].upper()}" + + +def _map_integrity(*, error_code: str, message: str) -> AppError: + return AppError(message, status_code=409, error_code=error_code) + + +class ReferralEngineService: + def __init__( + self, + session: AsyncSession, + publisher: TransactionalEventPublisher | None = None, + ) -> None: + self.session = session + self.programs = ReferralProgramRepository(session) + self.codes = ReferralCodeRepository(session) + self.attributions = ReferralAttributionRepository(session) + self.loyalty_programs = LoyaltyProgramRepository(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) + + # ------------------------------------------------------------------ + # Referral program CRUD + # ------------------------------------------------------------------ + + async def create_program( + self, + tenant_id: UUID, + body: ReferralProgramCreate, + *, + actor: CurrentUser | None = None, + ) -> ReferralProgram: + actor_id = actor.user_id if actor else None + if await self.loyalty_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_referral_program_status(body.status) + if await self.programs.get_by_code(tenant_id, body.program_id, code) is not None: + raise AppError( + "کد برنامه معرفی تکراری است", + status_code=409, + error_code="referral_program_code_exists", + ) + entity = ReferralProgram( + tenant_id=tenant_id, + program_id=body.program_id, + code=code, + name=name, + status=status, + referrer_bonus_points=body.referrer_bonus_points, + referee_bonus_points=body.referee_bonus_points, + max_referrals_per_member=body.max_referrals_per_member, + created_by=actor_id, + updated_by=actor_id, + ) + try: + await self.programs.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="referral_program", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + message=f"Referral program {entity.code} created", + ) + await self.publisher.publish( + event_type=LoyaltyEventType.REFERRAL_PROGRAM_CREATED, + aggregate_type="referral_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="referral_program_code_exists", + message="کد برنامه معرفی تکراری است", + ) + return entity + + async def update_program( + self, + tenant_id: UUID, + program_id: UUID, + body: ReferralProgramUpdate, + *, + actor: CurrentUser | None = None, + ) -> ReferralProgram: + entity = await self.get_program(tenant_id, program_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is None: + raise AppError( + "name نمی‌تواند null باشد", + status_code=422, + error_code="null_not_allowed", + details={"field": "name"}, + ) + if "status" in data and data["status"] is None: + raise AppError( + "status نمی‌تواند null باشد", + status_code=422, + error_code="null_not_allowed", + details={"field": "status"}, + ) + if "name" in data: + data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data: + data["status"] = validate_referral_program_status(data["status"]) + for key, value in data.items(): + setattr(entity, key, value) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type="referral_program", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=data, + ) + await self.publisher.publish( + event_type=LoyaltyEventType.REFERRAL_PROGRAM_UPDATED, + aggregate_type="referral_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_program(self, tenant_id: UUID, program_id: UUID) -> ReferralProgram: + entity = await self.programs.get(tenant_id, program_id) + if entity is None: + raise NotFoundError( + "برنامه معرفی یافت نشد", error_code="referral_program_not_found" + ) + return entity + + async def list_programs(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.programs.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete_program( + self, + tenant_id: UUID, + program_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ReferralProgram: + entity = await self.get_program(tenant_id, program_id) + entity.code = release_unique_token(entity.code, entity.id) + await self.programs.soft_delete( + entity, deleted_by=actor.user_id if actor else None + ) + await self.audit.record( + tenant_id=tenant_id, + entity_type="referral_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.REFERRAL_PROGRAM_DELETED, + aggregate_type="referral_program", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + # ------------------------------------------------------------------ + # Referral code issuance + # ------------------------------------------------------------------ + + async def issue_code( + self, + tenant_id: UUID, + body: ReferralCodeIssueRequest, + *, + actor: CurrentUser | None = None, + ) -> ReferralCode: + actor_id = actor.user_id if actor else None + program = await self.get_program(tenant_id, body.referral_program_id) + ensure_referral_program_active(program.status) + + 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 != program.program_id: + raise AppError( + "عضو متعلق به برنامه این طرح معرفی نیست", + status_code=409, + error_code="member_program_mismatch", + ) + + existing = await self.codes.get_active_for_member( + tenant_id, program.id, member.id + ) + if existing is not None: + return existing + + code_value = ( + validate_code(body.code, field="code") if body.code else _next_referral_code() + ) + if await self.codes.get_by_code(tenant_id, code_value) is not None: + raise AppError( + "کد معرفی تکراری است", + status_code=409, + error_code="referral_code_exists", + ) + + entity = ReferralCode( + tenant_id=tenant_id, + referral_program_id=program.id, + program_id=program.program_id, + member_id=member.id, + code=code_value, + status=ReferralCodeStatus.ACTIVE, + uses_count=0, + created_by=actor_id, + ) + try: + await self.codes.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="referral_code", + entity_id=entity.id, + action=AuditAction.ISSUE_CODE, + actor_user_id=actor_id, + message=f"Referral code {entity.code} issued to member {member.membership_number}", + ) + await self.publisher.publish( + event_type=LoyaltyEventType.REFERRAL_CODE_ISSUED, + aggregate_type="referral_code", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "member_id": str(member.id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + except IntegrityError: + await self.session.rollback() + raise _map_integrity( + error_code="referral_code_exists", + message="کد معرفی تکراری است", + ) + return entity + + async def revoke_code( + self, + tenant_id: UUID, + code_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ReferralCode: + entity = await self.get_code(tenant_id, code_id) + entity.status = ReferralCodeStatus.REVOKED + actor_id = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type="referral_code", + entity_id=entity.id, + action=AuditAction.REVOKE_CODE, + actor_user_id=actor_id, + message=f"Referral code {entity.code} revoked", + ) + await self.publisher.publish( + event_type=LoyaltyEventType.REFERRAL_CODE_REVOKED, + aggregate_type="referral_code", + 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_code(self, tenant_id: UUID, code_id: UUID) -> ReferralCode: + entity = await self.codes.get(tenant_id, code_id) + if entity is None: + raise NotFoundError( + "کد معرفی یافت نشد", error_code="referral_code_not_found" + ) + return entity + + async def list_codes( + self, + tenant_id: UUID, + *, + offset: int = 0, + limit: int = 50, + referral_program_id: UUID | None = None, + ): + rows = await self.codes.list_for_program( + tenant_id, referral_program_id, offset=offset, limit=limit + ) + total = await self.codes.count_for_program(tenant_id, referral_program_id) + return rows, total + + # ------------------------------------------------------------------ + # Attribution lifecycle: attribute -> convert -> reward | cancel + # ------------------------------------------------------------------ + + async def attribute( + self, + tenant_id: UUID, + body: ReferralAttributeRequest, + *, + actor: CurrentUser | None = None, + ) -> ReferralAttribution: + if body.idempotency_key: + existing = await self.attributions.get_by_idempotency( + tenant_id, body.idempotency_key + ) + if existing is not None: + return existing + + code = await self.codes.get_by_code(tenant_id, body.code) + if code is None: + raise NotFoundError( + "کد معرفی یافت نشد", error_code="referral_code_not_found" + ) + ensure_code_active(code.status) + + program = await self.get_program(tenant_id, code.referral_program_id) + ensure_referral_program_active(program.status) + + referee = await self.members.get(tenant_id, body.referee_member_id) + if referee is None: + raise NotFoundError("عضو یافت نشد", error_code="member_not_found") + + ensure_not_self_referral(code.member_id, referee.id) + + existing_attribution = await self.attributions.get_by_referee( + tenant_id, program.id, referee.id + ) + if existing_attribution is not None: + raise AppError( + "این عضو قبلا به این طرح معرفی ارجاع داده شده است", + status_code=409, + error_code="referral_referee_already_attributed", + ) + + existing_count = await self.attributions.count_for_referrer( + tenant_id, + program.id, + code.member_id, + exclude_statuses=(ReferralAttributionStatus.CANCELLED,), + ) + ensure_max_referrals_not_exceeded(existing_count, program.max_referrals_per_member) + + actor_id = actor.user_id if actor else None + attribution = ReferralAttribution( + tenant_id=tenant_id, + referral_program_id=program.id, + referral_code_id=code.id, + program_id=program.program_id, + referrer_member_id=code.member_id, + referee_member_id=referee.id, + status=ReferralAttributionStatus.PENDING, + idempotency_key=body.idempotency_key, + actor_user_id=actor_id, + ) + try: + await self.attributions.add(attribution) + code.uses_count += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="referral_attribution", + entity_id=attribution.id, + action=AuditAction.ATTRIBUTE, + actor_user_id=actor_id, + message=( + f"Referral code {code.code} attributed referee " + f"{referee.membership_number}" + ), + changes={ + "referral_code_id": str(code.id), + "referrer_member_id": str(code.member_id), + "referee_member_id": str(referee.id), + }, + ) + await self.publisher.publish( + event_type=LoyaltyEventType.REFERRAL_ATTRIBUTED, + aggregate_type="referral_attribution", + aggregate_id=attribution.id, + tenant_id=tenant_id, + payload={ + "referral_program_id": str(program.id), + "referrer_member_id": str(code.member_id), + "referee_member_id": str(referee.id), + }, + ) + await self.session.commit() + await self.session.refresh(attribution) + except IntegrityError: + await self.session.rollback() + raise _map_integrity( + error_code="referral_attribution_conflict", + message="این ارجاع معرفی تکراری است", + ) + return attribution + + async def get_attribution( + self, tenant_id: UUID, attribution_id: UUID + ) -> ReferralAttribution: + entity = await self.attributions.get(tenant_id, attribution_id) + if entity is None: + raise NotFoundError( + "ارجاع معرفی یافت نشد", error_code="referral_attribution_not_found" + ) + return entity + + async def convert( + self, + tenant_id: UUID, + attribution_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ReferralAttribution: + attribution = await self.get_attribution(tenant_id, attribution_id) + ensure_attribution_pending(attribution.status) + + referee = await self.members.get(tenant_id, attribution.referee_member_id) + if referee is None: + raise NotFoundError("عضو یافت نشد", error_code="member_not_found") + if referee.status != MemberStatus.ACTIVE: + raise AppError( + "عضو معرفی‌شده باید فعال باشد", + status_code=409, + error_code="referral_referee_not_active", + ) + + actor_id = actor.user_id if actor else None + attribution.status = ReferralAttributionStatus.CONVERTED + attribution.converted_at = _now() + attribution.actor_user_id = actor_id + await self.audit.record( + tenant_id=tenant_id, + entity_type="referral_attribution", + entity_id=attribution.id, + action=AuditAction.CONVERT, + actor_user_id=actor_id, + message="Referral attribution converted", + ) + await self.publisher.publish( + event_type=LoyaltyEventType.REFERRAL_CONVERTED, + aggregate_type="referral_attribution", + aggregate_id=attribution.id, + tenant_id=tenant_id, + payload={"referee_member_id": str(attribution.referee_member_id)}, + ) + await self.session.commit() + await self.session.refresh(attribution) + return attribution + + async def reward( + self, + tenant_id: UUID, + attribution_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ReferralAttribution: + attribution = await self.get_attribution(tenant_id, attribution_id) + ensure_attribution_converted(attribution.status) + + program = await self.get_program(tenant_id, attribution.referral_program_id) + + referrer_account = await self.accounts.get_by_member( + tenant_id, attribution.referrer_member_id + ) + if referrer_account is None: + raise NotFoundError( + "حساب امتیاز معرف یافت نشد", error_code="point_account_not_found" + ) + referee_account = await self.accounts.get_by_member( + tenant_id, attribution.referee_member_id + ) + if referee_account is None: + raise NotFoundError( + "حساب امتیاز معرفی‌شده یافت نشد", error_code="point_account_not_found" + ) + + actor_id = actor.user_id if actor else None + referrer_entry_id = attribution.referrer_ledger_entry_id + referee_entry_id = attribution.referee_ledger_entry_id + + if program.referrer_bonus_points > 0: + referrer_entry = await self.point_engine.earn( + tenant_id, + referrer_account.id, + PointEarnRequest( + amount=program.referrer_bonus_points, + reason=f"referral:{program.code}:referrer", + reference_type="referral_attribution", + reference_id=str(attribution.id), + idempotency_key=f"referral-reward-{attribution.id}-referrer", + ), + actor=actor, + auto_commit=False, + ) + referrer_entry_id = referrer_entry.id + + if program.referee_bonus_points > 0: + referee_entry = await self.point_engine.earn( + tenant_id, + referee_account.id, + PointEarnRequest( + amount=program.referee_bonus_points, + reason=f"referral:{program.code}:referee", + reference_type="referral_attribution", + reference_id=str(attribution.id), + idempotency_key=f"referral-reward-{attribution.id}-referee", + ), + actor=actor, + auto_commit=False, + ) + referee_entry_id = referee_entry.id + + attribution.status = ReferralAttributionStatus.REWARDED + attribution.rewarded_at = _now() + attribution.referrer_ledger_entry_id = referrer_entry_id + attribution.referee_ledger_entry_id = referee_entry_id + attribution.actor_user_id = actor_id + await self.audit.record( + tenant_id=tenant_id, + entity_type="referral_attribution", + entity_id=attribution.id, + action=AuditAction.REWARD, + actor_user_id=actor_id, + message="Referral attribution rewarded", + changes={ + "referrer_bonus_points": program.referrer_bonus_points, + "referee_bonus_points": program.referee_bonus_points, + }, + ) + await self.publisher.publish( + event_type=LoyaltyEventType.REFERRAL_REWARDED, + aggregate_type="referral_attribution", + aggregate_id=attribution.id, + tenant_id=tenant_id, + payload={ + "referrer_member_id": str(attribution.referrer_member_id), + "referee_member_id": str(attribution.referee_member_id), + "referrer_bonus_points": program.referrer_bonus_points, + "referee_bonus_points": program.referee_bonus_points, + }, + ) + await self.session.commit() + await self.session.refresh(attribution) + return attribution + + async def cancel( + self, + tenant_id: UUID, + attribution_id: UUID, + body: ReferralCancelRequest, + *, + actor: CurrentUser | None = None, + ) -> ReferralAttribution: + attribution = await self.get_attribution(tenant_id, attribution_id) + ensure_attribution_cancellable(attribution.status) + + actor_id = actor.user_id if actor else None + attribution.status = ReferralAttributionStatus.CANCELLED + attribution.cancelled_at = _now() + attribution.cancel_reason = body.reason + attribution.actor_user_id = actor_id + await self.audit.record( + tenant_id=tenant_id, + entity_type="referral_attribution", + entity_id=attribution.id, + action=AuditAction.CANCEL, + actor_user_id=actor_id, + message="Referral attribution cancelled", + changes={"reason": body.reason}, + ) + await self.publisher.publish( + event_type=LoyaltyEventType.REFERRAL_CANCELLED, + aggregate_type="referral_attribution", + aggregate_id=attribution.id, + tenant_id=tenant_id, + payload={"reason": body.reason}, + ) + await self.session.commit() + await self.session.refresh(attribution) + return attribution + + async def list_attributions( + self, + tenant_id: UUID, + *, + offset: int = 0, + limit: int = 50, + referral_program_id: UUID | None = None, + ): + rows = await self.attributions.list_for_program( + tenant_id, referral_program_id, offset=offset, limit=limit + ) + total = await self.attributions.count_for_program(tenant_id, referral_program_id) + return rows, total diff --git a/backend/services/loyalty/app/services/rewards_engine.py b/backend/services/loyalty/app/services/rewards_engine.py new file mode 100644 index 0000000..37f59bf --- /dev/null +++ b/backend/services/loyalty/app/services/rewards_engine.py @@ -0,0 +1,293 @@ +"""Rewards Engine — redemption lifecycle on top of the immutable point ledger (Phase 7.3).""" +from __future__ import annotations + +from datetime import datetime, timezone +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.rewards import RewardRedemption +from app.models.types import AuditAction, RedemptionStatus +from app.repositories.foundation import ( + LoyaltyProgramRepository, + MemberRepository, + PointAccountRepository, + RewardRepository, +) +from app.repositories.rewards import RewardRedemptionRepository +from app.schemas.ledger import PointEarnRequest, PointRedeemRequest +from app.schemas.rewards import CancelRequest, RedeemRequest +from app.services.audit_service import AuditService +from app.services.point_engine import PointEngineService +from app.validators import ( + ensure_cancellable, + ensure_fulfillable, + ensure_max_per_member, + ensure_member_active, + ensure_reward_active, + ensure_stock_available, + validate_reason, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _next_voucher_code() -> str: + return f"VCH-{uuid4().hex[:10].upper()}" + + +class RewardsEngineService: + def __init__( + self, + session: AsyncSession, + publisher: TransactionalEventPublisher | None = None, + ) -> None: + self.session = session + self.rewards = RewardRepository(session) + self.redemptions = RewardRedemptionRepository(session) + self.members = MemberRepository(session) + self.accounts = PointAccountRepository(session) + self.programs = LoyaltyProgramRepository(session) + self.audit = AuditService(session) + self.publisher = publisher or TransactionalEventPublisher(session) + self.point_engine = PointEngineService(session, publisher=self.publisher) + + async def redeem( + self, + tenant_id: UUID, + reward_id: UUID, + body: RedeemRequest, + *, + actor: CurrentUser | None = None, + ) -> RewardRedemption: + if body.idempotency_key: + existing = await self.redemptions.get_by_idempotency( + tenant_id, body.idempotency_key + ) + if existing is not None: + return existing + + reward = await self.rewards.get(tenant_id, reward_id) + if reward is None: + raise NotFoundError("پاداش یافت نشد", error_code="reward_not_found") + ensure_reward_active(reward) + + 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 != reward.program_id: + raise AppError( + "عضو متعلق به برنامه این پاداش نیست", + status_code=409, + error_code="member_program_mismatch", + ) + ensure_member_active(member.status) + + account = await self.accounts.get_by_member(tenant_id, member.id) + if account is None: + raise NotFoundError( + "حساب امتیاز یافت نشد", error_code="point_account_not_found" + ) + + ensure_stock_available(reward) + existing_count = await self.redemptions.count_for_member_reward( + tenant_id, member.id, reward.id + ) + ensure_max_per_member(existing_count, reward.max_per_member) + + actor_id = actor.user_id if actor else None + ledger_entry_id = None + points_spent = 0 + if reward.points_cost > 0: + entry = await self.point_engine.redeem( + tenant_id, + account.id, + PointRedeemRequest( + amount=reward.points_cost, + reason=f"reward:{reward.code}", + reference_type="reward_redemption", + reference_id=str(reward.id), + ), + actor=actor, + auto_commit=False, + ) + ledger_entry_id = entry.id + points_spent = reward.points_cost + + redemption = RewardRedemption( + tenant_id=tenant_id, + program_id=reward.program_id, + reward_id=reward.id, + member_id=member.id, + point_account_id=account.id, + status=RedemptionStatus.PENDING, + points_spent=points_spent, + ledger_entry_id=ledger_entry_id, + voucher_code=_next_voucher_code(), + idempotency_key=body.idempotency_key, + reason=body.reason, + metadata_json=body.metadata_json, + actor_user_id=actor_id, + ) + try: + await self.redemptions.add(redemption) + except IntegrityError: + await self.session.rollback() + raise AppError( + "درخواست بازخرید تکراری است", + status_code=409, + error_code="redemption_idempotency_conflict", + ) + reward.redeemed_count += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="reward_redemption", + entity_id=redemption.id, + action=AuditAction.REDEEM_REWARD, + actor_user_id=actor_id, + message=f"Reward {reward.code} redeemed by member {member.membership_number}", + changes={"points_spent": points_spent, "reward_id": str(reward.id)}, + ) + await self.publisher.publish( + event_type=LoyaltyEventType.REWARD_REDEEMED, + aggregate_type="reward_redemption", + aggregate_id=redemption.id, + tenant_id=tenant_id, + payload={ + "reward_id": str(reward.id), + "member_id": str(member.id), + "points_spent": points_spent, + "voucher_code": redemption.voucher_code, + }, + ) + await self.session.commit() + await self.session.refresh(redemption) + return redemption + + async def fulfill( + self, + tenant_id: UUID, + redemption_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> RewardRedemption: + entity = await self.get(tenant_id, redemption_id) + ensure_fulfillable(entity.status) + actor_id = actor.user_id if actor else None + entity.status = RedemptionStatus.FULFILLED + entity.fulfilled_at = _now() + await self.audit.record( + tenant_id=tenant_id, + entity_type="reward_redemption", + entity_id=entity.id, + action=AuditAction.FULFILL_REWARD, + actor_user_id=actor_id, + message=f"Redemption {entity.voucher_code} fulfilled", + ) + await self.publisher.publish( + event_type=LoyaltyEventType.REWARD_FULFILLED, + aggregate_type="reward_redemption", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"voucher_code": entity.voucher_code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def cancel( + self, + tenant_id: UUID, + redemption_id: UUID, + body: CancelRequest, + *, + actor: CurrentUser | None = None, + ) -> RewardRedemption: + entity = await self.get(tenant_id, redemption_id) + ensure_cancellable(entity.status) + reason = validate_reason(body.reason, required=True, field="reason") + actor_id = actor.user_id if actor else None + + if entity.points_spent > 0: + account = await self.accounts.get(tenant_id, entity.point_account_id) + if account is None: + raise NotFoundError( + "حساب امتیاز یافت نشد", error_code="point_account_not_found" + ) + await self.point_engine.earn( + tenant_id, + account.id, + PointEarnRequest( + amount=entity.points_spent, + reason=f"refund:{entity.voucher_code}", + reference_type="reward_redemption_cancel", + reference_id=str(entity.id), + ), + actor=actor, + auto_commit=False, + ) + + reward = await self.rewards.get(tenant_id, entity.reward_id) + if reward is not None and reward.redeemed_count > 0: + reward.redeemed_count -= 1 + + entity.status = RedemptionStatus.CANCELLED + entity.cancelled_at = _now() + entity.cancel_reason = reason + await self.audit.record( + tenant_id=tenant_id, + entity_type="reward_redemption", + entity_id=entity.id, + action=AuditAction.CANCEL_REDEMPTION, + actor_user_id=actor_id, + message=f"Redemption {entity.voucher_code} cancelled", + changes={"reason": reason, "points_refunded": entity.points_spent}, + ) + await self.publisher.publish( + event_type=LoyaltyEventType.REWARD_REDEMPTION_CANCELLED, + aggregate_type="reward_redemption", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"voucher_code": entity.voucher_code, "reason": reason}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, redemption_id: UUID) -> RewardRedemption: + entity = await self.redemptions.get(tenant_id, redemption_id) + if entity is None: + raise NotFoundError( + "درخواست بازخرید یافت نشد", error_code="redemption_not_found" + ) + return entity + + async def list( + self, + tenant_id: UUID, + *, + reward_id: UUID | None = None, + member_id: UUID | None = None, + status: RedemptionStatus | None = None, + offset: int = 0, + limit: int = 50, + ): + rows = await self.redemptions.list( + tenant_id, + reward_id=reward_id, + member_id=member_id, + status=status, + offset=offset, + limit=limit, + ) + total = await self.redemptions.count( + tenant_id, reward_id=reward_id, member_id=member_id, status=status + ) + return rows, total diff --git a/backend/services/loyalty/app/services/wallet_engine.py b/backend/services/loyalty/app/services/wallet_engine.py new file mode 100644 index 0000000..e69f3f5 --- /dev/null +++ b/backend/services/loyalty/app/services/wallet_engine.py @@ -0,0 +1,487 @@ +"""Wallet Engine — immutable ledger writes and account lifecycle (Phase 7.6). + +Mirrors the Point Engine (Phase 7.2): WalletAccount is a shell only; balance +is always SUM(signed WalletLedgerEntry.amount). Never add a mutable balance +column on WalletAccount. +""" +from __future__ import annotations + +from datetime import datetime, timezone +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.types import AuditAction, WalletAccountStatus, WalletLedgerEntryType +from app.models.wallet import WalletAccount, WalletLedgerEntry +from app.repositories.foundation import LoyaltyProgramRepository, MemberRepository +from app.repositories.wallet import WalletAccountRepository, WalletLedgerRepository +from app.schemas.wallet import ( + WalletAccountCreate, + WalletAccountUpdate, + WalletAdjustRequest, + WalletCreditRequest, + WalletDebitRequest, + WalletTransferRequest, +) +from app.services.audit_service import AuditService +from app.validators.foundation import ( + release_unique_token, + validate_code, + validate_currency_code, +) +from app.validators.wallet import ( + ensure_not_same_account, + ensure_same_currency, + ensure_wallet_open, + signed_amount, + validate_adjust_amount, + validate_positive_amount, + validate_wallet_account_status, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _next_wallet_account_number() -> str: + return f"WA-{uuid4().hex[:10].upper()}" + + +def _map_integrity(*, error_code: str, message: str) -> AppError: + return AppError(message, status_code=409, error_code=error_code) + + +class WalletEngineService: + def __init__( + self, + session: AsyncSession, + publisher: TransactionalEventPublisher | None = None, + ) -> None: + self.session = session + self.accounts = WalletAccountRepository(session) + self.ledger = WalletLedgerRepository(session) + self.programs = LoyaltyProgramRepository(session) + self.members = MemberRepository(session) + self.audit = AuditService(session) + self.publisher = publisher or TransactionalEventPublisher(session) + + # ------------------------------------------------------------------ + # Account lifecycle + # ------------------------------------------------------------------ + + async def create( + self, + tenant_id: UUID, + body: WalletAccountCreate, + *, + actor: CurrentUser | None = None, + ) -> WalletAccount: + 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.accounts.get_by_member_program( + tenant_id, body.program_id, body.member_id + ) + is not None + ): + raise AppError( + "کیف پول برای این عضو در این برنامه وجود دارد", + status_code=409, + error_code="wallet_account_exists", + ) + account_number = ( + validate_code(body.account_number, field="account_number") + if body.account_number + else _next_wallet_account_number() + ) + if await self.accounts.get_by_account_number(tenant_id, account_number): + raise AppError( + "شماره کیف پول تکراری است", + status_code=409, + error_code="wallet_account_number_exists", + ) + entity = WalletAccount( + tenant_id=tenant_id, + program_id=body.program_id, + member_id=body.member_id, + account_number=account_number, + currency_code=validate_currency_code(body.currency_code), + status=validate_wallet_account_status(body.status), + created_by=actor_id, + updated_by=actor_id, + ) + try: + await self.accounts.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="wallet_account", + entity_id=entity.id, + action=AuditAction.WALLET_OPEN, + actor_user_id=actor_id, + message=f"Wallet {entity.account_number} opened", + ) + await self.publisher.publish( + event_type=LoyaltyEventType.WALLET_OPENED, + aggregate_type="wallet_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="wallet_account_conflict", + message="کیف پول قابل ایجاد نیست", + ) + return entity + + async def update( + self, + tenant_id: UUID, + account_id: UUID, + body: WalletAccountUpdate, + *, + actor: CurrentUser | None = None, + ) -> WalletAccount: + entity = await self.get(tenant_id, account_id) + data = body.model_dump(exclude_unset=True) + if "status" in data: + if data["status"] is None: + raise AppError( + "status نمی‌تواند null باشد", + status_code=422, + error_code="null_not_allowed", + details={"field": "status"}, + ) + entity.status = validate_wallet_account_status(data["status"]) + actor_id = actor.user_id if actor else None + entity.updated_by = actor_id + await self.audit.record( + tenant_id=tenant_id, + entity_type="wallet_account", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=actor_id, + changes={"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) -> WalletAccount: + entity = await self.accounts.get(tenant_id, account_id) + if entity is None: + raise NotFoundError( + "کیف پول یافت نشد", error_code="wallet_account_not_found" + ) + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.accounts.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def _get_open_account(self, tenant_id: UUID, account_id: UUID) -> WalletAccount: + entity = await self.get(tenant_id, account_id) + ensure_wallet_open(entity.status) + return entity + + async def get_balance(self, tenant_id: UUID, account_id: UUID) -> dict: + entity = await self.get(tenant_id, account_id) + balance = await self.ledger.balance_for_account(tenant_id, account_id) + return { + "wallet_account_id": account_id, + "member_id": entity.member_id, + "program_id": entity.program_id, + "status": entity.status, + "currency_code": entity.currency_code, + "balance": balance, + } + + async def list_ledger( + self, + tenant_id: UUID, + account_id: UUID, + *, + entry_type: WalletLedgerEntryType | None = None, + offset: int = 0, + limit: int = 50, + ): + await self.get(tenant_id, account_id) + rows = await self.ledger.list_for_account( + tenant_id, account_id, entry_type=entry_type, offset=offset, limit=limit + ) + total = await self.ledger.count_for_account( + tenant_id, account_id, entry_type=entry_type + ) + return rows, total + + async def soft_delete( + self, + tenant_id: UUID, + account_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> WalletAccount: + entity = await self.get(tenant_id, account_id) + actor_id = actor.user_id if actor else None + entity.account_number = release_unique_token(entity.account_number, entity.id) + entity.status = WalletAccountStatus.CLOSED + await self.accounts.soft_delete(entity, deleted_by=actor_id) + await self.audit.record( + tenant_id=tenant_id, + entity_type="wallet_account", + entity_id=entity.id, + action=AuditAction.WALLET_CLOSE, + actor_user_id=actor_id, + ) + await self.publisher.publish( + event_type=LoyaltyEventType.WALLET_CLOSED, + aggregate_type="wallet_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 + + # ------------------------------------------------------------------ + # Ledger writes + # ------------------------------------------------------------------ + + async def _append( + self, + *, + tenant_id: UUID, + account: WalletAccount, + entry_type: WalletLedgerEntryType, + signed: int, + body_idempotency: str | None, + reason: str | None, + reference_type: str | None, + reference_id: str | None, + metadata: dict | None, + actor: CurrentUser | None, + event_type: LoyaltyEventType, + audit_action: AuditAction, + auto_commit: bool = True, + ) -> WalletLedgerEntry: + if body_idempotency: + existing = await self.ledger.get_by_idempotency(tenant_id, body_idempotency) + if existing is not None: + return existing + balance = await self.ledger.balance_for_account(tenant_id, account.id) + new_balance = balance + signed + if new_balance < 0: + raise AppError( + "موجودی کیف پول کافی نیست", + status_code=409, + error_code="wallet_insufficient_funds", + details={"balance": balance, "requested": signed}, + ) + actor_id = actor.user_id if actor else None + entry = WalletLedgerEntry( + tenant_id=tenant_id, + wallet_account_id=account.id, + program_id=account.program_id, + member_id=account.member_id, + entry_type=entry_type, + amount=signed, + balance_after=new_balance, + idempotency_key=body_idempotency, + reference_type=reference_type, + reference_id=reference_id, + reason=reason, + metadata_json=metadata, + actor_user_id=actor_id, + created_at=_now(), + ) + await self.ledger.add(entry) + await self.audit.record( + tenant_id=tenant_id, + entity_type="wallet_ledger_entry", + entity_id=entry.id, + action=audit_action, + actor_user_id=actor_id, + message=f"{entry_type.value} {signed} on {account.account_number}", + changes={ + "amount": signed, + "balance_after": new_balance, + "wallet_account_id": str(account.id), + }, + ) + await self.publisher.publish( + event_type=event_type, + aggregate_type="wallet_ledger_entry", + aggregate_id=entry.id, + tenant_id=tenant_id, + payload={ + "wallet_account_id": str(account.id), + "entry_type": entry_type.value, + "amount": signed, + "balance_after": new_balance, + }, + ) + if auto_commit: + await self.session.commit() + await self.session.refresh(entry) + return entry + + async def credit( + self, + tenant_id: UUID, + account_id: UUID, + body: WalletCreditRequest, + *, + actor: CurrentUser | None = None, + auto_commit: bool = True, + ) -> WalletLedgerEntry: + account = await self._get_open_account(tenant_id, account_id) + amount = validate_positive_amount(body.amount) + return await self._append( + tenant_id=tenant_id, + account=account, + entry_type=WalletLedgerEntryType.CREDIT, + signed=signed_amount(WalletLedgerEntryType.CREDIT, amount), + body_idempotency=body.idempotency_key, + reason=body.reason, + reference_type=body.reference_type, + reference_id=body.reference_id, + metadata=body.metadata, + actor=actor, + event_type=LoyaltyEventType.WALLET_CREDITED, + audit_action=AuditAction.WALLET_CREDIT, + auto_commit=auto_commit, + ) + + async def debit( + self, + tenant_id: UUID, + account_id: UUID, + body: WalletDebitRequest, + *, + actor: CurrentUser | None = None, + auto_commit: bool = True, + ) -> WalletLedgerEntry: + account = await self._get_open_account(tenant_id, account_id) + amount = validate_positive_amount(body.amount) + return await self._append( + tenant_id=tenant_id, + account=account, + entry_type=WalletLedgerEntryType.DEBIT, + signed=signed_amount(WalletLedgerEntryType.DEBIT, amount), + body_idempotency=body.idempotency_key, + reason=body.reason, + reference_type=body.reference_type, + reference_id=body.reference_id, + metadata=body.metadata, + actor=actor, + event_type=LoyaltyEventType.WALLET_DEBITED, + audit_action=AuditAction.WALLET_DEBIT, + auto_commit=auto_commit, + ) + + async def adjust( + self, + tenant_id: UUID, + account_id: UUID, + body: WalletAdjustRequest, + *, + actor: CurrentUser | None = None, + auto_commit: bool = True, + ) -> WalletLedgerEntry: + account = await self._get_open_account(tenant_id, account_id) + amount = validate_adjust_amount(body.amount) + return await self._append( + tenant_id=tenant_id, + account=account, + entry_type=WalletLedgerEntryType.ADJUST, + signed=signed_amount(WalletLedgerEntryType.ADJUST, amount), + body_idempotency=body.idempotency_key, + reason=body.reason, + reference_type=body.reference_type, + reference_id=body.reference_id, + metadata=body.metadata, + actor=actor, + event_type=LoyaltyEventType.WALLET_ADJUSTED, + audit_action=AuditAction.WALLET_ADJUST, + auto_commit=auto_commit, + ) + + async def transfer( + self, + tenant_id: UUID, + source_account_id: UUID, + body: WalletTransferRequest, + *, + actor: CurrentUser | None = None, + ) -> tuple[WalletLedgerEntry, WalletLedgerEntry]: + source = await self._get_open_account(tenant_id, source_account_id) + target = await self._get_open_account(tenant_id, body.target_wallet_id) + ensure_not_same_account(source.id, target.id) + ensure_same_currency(source.currency_code, target.currency_code) + amount = validate_positive_amount(body.amount) + + out_key = f"{body.idempotency_key}:out" if body.idempotency_key else None + in_key = f"{body.idempotency_key}:in" if body.idempotency_key else None + + if out_key: + existing_out = await self.ledger.get_by_idempotency(tenant_id, out_key) + if existing_out is not None: + existing_in = await self.ledger.get_by_idempotency(tenant_id, in_key) + return existing_out, existing_in + + transfer_group = str(uuid4()) + base_metadata = dict(body.metadata or {}) + + out_entry = await self._append( + tenant_id=tenant_id, + account=source, + entry_type=WalletLedgerEntryType.TRANSFER_OUT, + signed=signed_amount(WalletLedgerEntryType.TRANSFER_OUT, amount), + body_idempotency=out_key, + reason=body.reason, + reference_type="wallet_transfer", + reference_id=transfer_group, + metadata={**base_metadata, "transfer_group": transfer_group, "counterparty_wallet_id": str(target.id)}, + actor=actor, + event_type=LoyaltyEventType.WALLET_TRANSFERRED, + audit_action=AuditAction.WALLET_TRANSFER, + auto_commit=False, + ) + in_entry = await self._append( + tenant_id=tenant_id, + account=target, + entry_type=WalletLedgerEntryType.TRANSFER_IN, + signed=signed_amount(WalletLedgerEntryType.TRANSFER_IN, amount), + body_idempotency=in_key, + reason=body.reason, + reference_type="wallet_transfer", + reference_id=transfer_group, + metadata={**base_metadata, "transfer_group": transfer_group, "counterparty_wallet_id": str(source.id)}, + actor=actor, + event_type=LoyaltyEventType.WALLET_TRANSFERRED, + audit_action=AuditAction.WALLET_TRANSFER, + auto_commit=False, + ) + await self.session.commit() + await self.session.refresh(out_entry) + await self.session.refresh(in_entry) + return out_entry, in_entry diff --git a/backend/services/loyalty/app/tests/test_api.py b/backend/services/loyalty/app/tests/test_api.py index 7bcfcc5..ad0a8a0 100644 --- a/backend/services/loyalty/app/tests/test_api.py +++ b/backend/services/loyalty/app/tests/test_api.py @@ -15,7 +15,7 @@ async def test_health(client): body = resp.json() assert body["service"] == "loyalty-service" assert body["status"] == "ok" - assert body["version"] == "0.7.1.0" + assert body["version"] == "0.7.6.0" @pytest.mark.asyncio diff --git a/backend/services/loyalty/app/tests/test_migration.py b/backend/services/loyalty/app/tests/test_migration.py index 855cd9b..b9a1b9e 100644 --- a/backend/services/loyalty/app/tests/test_migration.py +++ b/backend/services/loyalty/app/tests/test_migration.py @@ -12,9 +12,9 @@ def test_alembic_revisions_exist(): scripts = ScriptDirectory.from_config(cfg) revisions = list(scripts.walk_revisions()) assert revisions - assert revisions[0].revision == "0002_phase_71_membership" + assert revisions[0].revision == "0007_phase_76_wallet" assert revisions[-1].revision == "0001_initial" - assert revisions[0].down_revision == "0001_initial" + assert revisions[0].down_revision == "0006_phase_75_referral" assert revisions[-1].down_revision is None @@ -32,6 +32,14 @@ def test_migration_upgrade_creates_metadata_tables(db_setup): "loyalty_audit_logs", "outbox_events", "membership_lifecycle_events", + "point_ledger_entries", + "reward_redemptions", + "campaign_applications", + "referral_programs", + "referral_codes", + "referral_attributions", + "wallet_accounts", + "wallet_ledger_entries", } assert expected.issubset(set(Base.metadata.tables.keys())) assert "ix_members_tenant_tier" in { diff --git a/backend/services/loyalty/app/tests/test_permissions.py b/backend/services/loyalty/app/tests/test_permissions.py index 6637a66..21ff993 100644 --- a/backend/services/loyalty/app/tests/test_permissions.py +++ b/backend/services/loyalty/app/tests/test_permissions.py @@ -16,6 +16,7 @@ def test_required_permission_trees_present(): "loyalty.members.", "loyalty.point_accounts.", "loyalty.rewards.", + "loyalty.redemptions.", "loyalty.campaigns.", "loyalty.audit.", ] @@ -29,6 +30,7 @@ def test_permission_prefixes_documented(): assert "loyalty.members." in PERMISSION_PREFIXES assert "loyalty.point_accounts." in PERMISSION_PREFIXES assert "loyalty.rewards." in PERMISSION_PREFIXES + assert "loyalty.redemptions." in PERMISSION_PREFIXES assert "loyalty.campaigns." in PERMISSION_PREFIXES diff --git a/backend/services/loyalty/app/tests/test_phase72.py b/backend/services/loyalty/app/tests/test_phase72.py new file mode 100644 index 0000000..d40460a --- /dev/null +++ b/backend/services/loyalty/app/tests/test_phase72.py @@ -0,0 +1,114 @@ +"""Phase 7.2 — Point Engine tests.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import LoyaltyEventType +from app.tests.conftest import TENANT_A, tenant_headers + + +async def _open_account(client, *, suffix="a"): + program = await client.post( + "/api/v1/programs", + json={"code": f"pts{suffix}", "name": f"Points {suffix}"}, + headers=tenant_headers(TENANT_A), + ) + assert program.status_code == 201, program.text + program_id = program.json()["id"] + member = await client.post( + "/api/v1/members", + json={"program_id": program_id, "display_name": "P", "enroll": True}, + headers=tenant_headers(TENANT_A), + ) + member_id = member.json()["id"] + account = await client.post( + "/api/v1/point-accounts", + json={"program_id": program_id, "member_id": member_id}, + headers=tenant_headers(TENANT_A), + ) + assert account.status_code == 201, account.text + return account.json()["id"] + + +@pytest.mark.asyncio +async def test_earn_redeem_balance_and_idempotency(client): + account_id = await _open_account(client) + earn = await client.post( + f"/api/v1/point-accounts/{account_id}/earn", + json={"amount": 100, "reason": "purchase", "idempotency_key": "e1"}, + headers=tenant_headers(TENANT_A), + ) + assert earn.status_code == 200, earn.text + assert earn.json()["balance_after"] == 100 + + again = await client.post( + f"/api/v1/point-accounts/{account_id}/earn", + json={"amount": 100, "reason": "purchase", "idempotency_key": "e1"}, + headers=tenant_headers(TENANT_A), + ) + assert again.status_code == 200 + assert again.json()["id"] == earn.json()["id"] + + bal = await client.get( + f"/api/v1/point-accounts/{account_id}/balance", + headers=tenant_headers(TENANT_A), + ) + assert bal.status_code == 200 + assert bal.json()["balance"] == 100 + assert "balance" in bal.json() + + redeem = await client.post( + f"/api/v1/point-accounts/{account_id}/redeem", + json={"amount": 40, "reason": "reward"}, + headers=tenant_headers(TENANT_A), + ) + assert redeem.status_code == 200 + assert redeem.json()["balance_after"] == 60 + + over = await client.post( + f"/api/v1/point-accounts/{account_id}/redeem", + json={"amount": 1000}, + headers=tenant_headers(TENANT_A), + ) + assert over.status_code == 409 + assert over.json()["error"]["code"] == "insufficient_points" + + events = {e.event_type for e in get_event_publisher().published} + assert LoyaltyEventType.POINTS_EARNED.value in events + assert LoyaltyEventType.POINTS_REDEEMED.value in events + + +@pytest.mark.asyncio +async def test_adjust_expire_and_ledger_list(client): + account_id = await _open_account(client, suffix="adj") + await client.post( + f"/api/v1/point-accounts/{account_id}/earn", + json={"amount": 50}, + headers=tenant_headers(TENANT_A), + ) + adj = await client.post( + f"/api/v1/point-accounts/{account_id}/adjust", + json={"amount": -10, "reason": "correction"}, + headers=tenant_headers(TENANT_A), + ) + assert adj.status_code == 200 + assert adj.json()["balance_after"] == 40 + + exp = await client.post( + f"/api/v1/point-accounts/{account_id}/expire-points", + json={"amount": 5, "reason": "aged"}, + headers=tenant_headers(TENANT_A), + ) + assert exp.status_code == 200 + assert exp.json()["balance_after"] == 35 + + page = await client.get( + f"/api/v1/point-accounts/{account_id}/ledger", + params={"limit": 10}, + headers=tenant_headers(TENANT_A), + ) + assert page.status_code == 200 + body = page.json() + assert body["total"] >= 3 + assert len(body["items"]) >= 3 diff --git a/backend/services/loyalty/app/tests/test_phase73.py b/backend/services/loyalty/app/tests/test_phase73.py new file mode 100644 index 0000000..9957fc3 --- /dev/null +++ b/backend/services/loyalty/app/tests/test_phase73.py @@ -0,0 +1,359 @@ +"""Phase 7.3 — Rewards Engine tests.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import LoyaltyEventType +from app.tests.conftest import TENANT_A, tenant_headers + + +async def _setup( + client, + *, + suffix: str, + points_cost: int = 50, + stock_limit: int | None = None, + max_per_member: int | None = None, + earn_amount: int = 100, +) -> dict: + program = await client.post( + "/api/v1/programs", + json={"code": f"rwd{suffix}", "name": f"Rewards {suffix}"}, + headers=tenant_headers(TENANT_A), + ) + assert program.status_code == 201, program.text + program_id = program.json()["id"] + + member = await client.post( + "/api/v1/members", + json={"program_id": program_id, "display_name": "Member", "enroll": True}, + headers=tenant_headers(TENANT_A), + ) + assert member.status_code == 201, member.text + member_id = member.json()["id"] + + account = await client.post( + "/api/v1/point-accounts", + json={"program_id": program_id, "member_id": member_id}, + headers=tenant_headers(TENANT_A), + ) + assert account.status_code == 201, account.text + account_id = account.json()["id"] + + if earn_amount: + earn = await client.post( + f"/api/v1/point-accounts/{account_id}/earn", + json={"amount": earn_amount, "reason": "seed"}, + headers=tenant_headers(TENANT_A), + ) + assert earn.status_code == 200, earn.text + + reward_payload = { + "program_id": program_id, + "code": f"rew{suffix}", + "name": f"Reward {suffix}", + "reward_type": "gift", + "status": "active", + "points_cost": points_cost, + } + if stock_limit is not None: + reward_payload["stock_limit"] = stock_limit + if max_per_member is not None: + reward_payload["max_per_member"] = max_per_member + reward = await client.post( + "/api/v1/rewards", json=reward_payload, headers=tenant_headers(TENANT_A) + ) + assert reward.status_code == 201, reward.text + + return { + "program_id": program_id, + "member_id": member_id, + "account_id": account_id, + "reward_id": reward.json()["id"], + } + + +@pytest.mark.asyncio +async def test_redeem_debits_points_and_creates_redemption(client): + ctx = await _setup(client, suffix="r1", points_cost=40, earn_amount=100) + redeem = await client.post( + f"/api/v1/rewards/{ctx['reward_id']}/redeem", + json={"member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + assert redeem.status_code == 201, redeem.text + body = redeem.json() + assert body["status"] == "pending" + assert body["points_spent"] == 40 + assert body["voucher_code"] + assert body["ledger_entry_id"] is not None + + balance = await client.get( + f"/api/v1/point-accounts/{ctx['account_id']}/balance", + headers=tenant_headers(TENANT_A), + ) + assert balance.json()["balance"] == 60 + + reward = await client.get( + f"/api/v1/rewards/{ctx['reward_id']}", headers=tenant_headers(TENANT_A) + ) + assert reward.json()["redeemed_count"] == 1 + + events = {e.event_type for e in get_event_publisher().published} + assert LoyaltyEventType.REWARD_REDEEMED.value in events + assert LoyaltyEventType.POINTS_REDEEMED.value in events + + +@pytest.mark.asyncio +async def test_redeem_free_reward_skips_ledger_debit(client): + ctx = await _setup(client, suffix="r0", points_cost=0, earn_amount=50) + redeem = await client.post( + f"/api/v1/rewards/{ctx['reward_id']}/redeem", + json={"member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + assert redeem.status_code == 201, redeem.text + body = redeem.json() + assert body["points_spent"] == 0 + assert body["ledger_entry_id"] is None + + balance = await client.get( + f"/api/v1/point-accounts/{ctx['account_id']}/balance", + headers=tenant_headers(TENANT_A), + ) + assert balance.json()["balance"] == 50 + + +@pytest.mark.asyncio +async def test_redeem_insufficient_points_rejected(client): + ctx = await _setup(client, suffix="r2", points_cost=500, earn_amount=100) + redeem = await client.post( + f"/api/v1/rewards/{ctx['reward_id']}/redeem", + json={"member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + assert redeem.status_code == 409 + assert redeem.json()["error"]["code"] == "insufficient_points" + + balance = await client.get( + f"/api/v1/point-accounts/{ctx['account_id']}/balance", + headers=tenant_headers(TENANT_A), + ) + assert balance.json()["balance"] == 100 + + reward = await client.get( + f"/api/v1/rewards/{ctx['reward_id']}", headers=tenant_headers(TENANT_A) + ) + assert reward.json()["redeemed_count"] == 0 + + +@pytest.mark.asyncio +async def test_redeem_respects_stock_limit(client): + ctx = await _setup(client, suffix="r3", points_cost=10, stock_limit=1, earn_amount=100) + first = await client.post( + f"/api/v1/rewards/{ctx['reward_id']}/redeem", + json={"member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + assert first.status_code == 201, first.text + + second = await client.post( + f"/api/v1/rewards/{ctx['reward_id']}/redeem", + json={"member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + assert second.status_code == 409 + assert second.json()["error"]["code"] == "reward_stock_exhausted" + + +@pytest.mark.asyncio +async def test_redeem_respects_max_per_member(client): + ctx = await _setup( + client, suffix="r4", points_cost=10, max_per_member=1, earn_amount=100 + ) + first = await client.post( + f"/api/v1/rewards/{ctx['reward_id']}/redeem", + json={"member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + assert first.status_code == 201, first.text + + second = await client.post( + f"/api/v1/rewards/{ctx['reward_id']}/redeem", + json={"member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + assert second.status_code == 409 + assert second.json()["error"]["code"] == "reward_max_per_member_exceeded" + + +@pytest.mark.asyncio +async def test_redeem_idempotency_returns_same_redemption(client): + ctx = await _setup(client, suffix="r5", points_cost=10, earn_amount=100) + first = await client.post( + f"/api/v1/rewards/{ctx['reward_id']}/redeem", + json={"member_id": ctx["member_id"], "idempotency_key": "idem-1"}, + headers=tenant_headers(TENANT_A), + ) + assert first.status_code == 201, first.text + + second = await client.post( + f"/api/v1/rewards/{ctx['reward_id']}/redeem", + json={"member_id": ctx["member_id"], "idempotency_key": "idem-1"}, + headers=tenant_headers(TENANT_A), + ) + assert second.status_code == 201 + assert second.json()["id"] == first.json()["id"] + + balance = await client.get( + f"/api/v1/point-accounts/{ctx['account_id']}/balance", + headers=tenant_headers(TENANT_A), + ) + assert balance.json()["balance"] == 90 + + +@pytest.mark.asyncio +async def test_fulfill_redemption(client): + ctx = await _setup(client, suffix="r6", points_cost=10, earn_amount=100) + redeem = await client.post( + f"/api/v1/rewards/{ctx['reward_id']}/redeem", + json={"member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + redemption_id = redeem.json()["id"] + + fulfill = await client.post( + f"/api/v1/redemptions/{redemption_id}/fulfill", + headers=tenant_headers(TENANT_A), + ) + assert fulfill.status_code == 200, fulfill.text + assert fulfill.json()["status"] == "fulfilled" + assert fulfill.json()["fulfilled_at"] is not None + + events = {e.event_type for e in get_event_publisher().published} + assert LoyaltyEventType.REWARD_FULFILLED.value in events + + again = await client.post( + f"/api/v1/redemptions/{redemption_id}/fulfill", + headers=tenant_headers(TENANT_A), + ) + assert again.status_code == 409 + assert again.json()["error"]["code"] == "redemption_not_pending" + + +@pytest.mark.asyncio +async def test_cancel_redemption_refunds_points(client): + ctx = await _setup(client, suffix="r7", points_cost=30, earn_amount=100) + redeem = await client.post( + f"/api/v1/rewards/{ctx['reward_id']}/redeem", + json={"member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + redemption_id = redeem.json()["id"] + + balance_before = await client.get( + f"/api/v1/point-accounts/{ctx['account_id']}/balance", + headers=tenant_headers(TENANT_A), + ) + assert balance_before.json()["balance"] == 70 + + cancel = await client.post( + f"/api/v1/redemptions/{redemption_id}/cancel", + json={"reason": "customer request"}, + headers=tenant_headers(TENANT_A), + ) + assert cancel.status_code == 200, cancel.text + assert cancel.json()["status"] == "cancelled" + assert cancel.json()["cancelled_at"] is not None + assert cancel.json()["cancel_reason"] == "customer request" + + balance_after = await client.get( + f"/api/v1/point-accounts/{ctx['account_id']}/balance", + headers=tenant_headers(TENANT_A), + ) + assert balance_after.json()["balance"] == 100 + + reward = await client.get( + f"/api/v1/rewards/{ctx['reward_id']}", headers=tenant_headers(TENANT_A) + ) + assert reward.json()["redeemed_count"] == 0 + + events = {e.event_type for e in get_event_publisher().published} + assert LoyaltyEventType.REWARD_REDEMPTION_CANCELLED.value in events + + again = await client.post( + f"/api/v1/redemptions/{redemption_id}/cancel", + json={"reason": "again"}, + headers=tenant_headers(TENANT_A), + ) + assert again.status_code == 409 + + +@pytest.mark.asyncio +async def test_cancel_requires_reason(client): + ctx = await _setup(client, suffix="r7b", points_cost=10, earn_amount=100) + redeem = await client.post( + f"/api/v1/rewards/{ctx['reward_id']}/redeem", + json={"member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + redemption_id = redeem.json()["id"] + bad = await client.post( + f"/api/v1/redemptions/{redemption_id}/cancel", + json={"reason": ""}, + headers=tenant_headers(TENANT_A), + ) + assert bad.status_code == 422 + + +@pytest.mark.asyncio +async def test_inactive_reward_rejected(client): + ctx = await _setup(client, suffix="r8", points_cost=10, earn_amount=100) + patched = await client.patch( + f"/api/v1/rewards/{ctx['reward_id']}", + json={"status": "inactive"}, + headers=tenant_headers(TENANT_A), + ) + assert patched.status_code == 200, patched.text + + redeem = await client.post( + f"/api/v1/rewards/{ctx['reward_id']}/redeem", + json={"member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + assert redeem.status_code == 409 + assert redeem.json()["error"]["code"] == "reward_not_active" + + +@pytest.mark.asyncio +async def test_list_redemptions_for_reward_and_top_level(client): + ctx = await _setup(client, suffix="r9", points_cost=10, earn_amount=100) + redeem = await client.post( + f"/api/v1/rewards/{ctx['reward_id']}/redeem", + json={"member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + assert redeem.status_code == 201, redeem.text + + listed = await client.get( + f"/api/v1/rewards/{ctx['reward_id']}/redemptions", + headers=tenant_headers(TENANT_A), + ) + assert listed.status_code == 200 + assert listed.json()["total"] >= 1 + + top_level = await client.get( + "/api/v1/redemptions", + params={"reward_id": ctx["reward_id"]}, + headers=tenant_headers(TENANT_A), + ) + assert top_level.status_code == 200 + assert top_level.json()["total"] >= 1 + + fetched = await client.get( + f"/api/v1/redemptions/{redeem.json()['id']}", + headers=tenant_headers(TENANT_A), + ) + assert fetched.status_code == 200 + assert fetched.json()["id"] == redeem.json()["id"] diff --git a/backend/services/loyalty/app/tests/test_phase74.py b/backend/services/loyalty/app/tests/test_phase74.py new file mode 100644 index 0000000..3c79f6c --- /dev/null +++ b/backend/services/loyalty/app/tests/test_phase74.py @@ -0,0 +1,402 @@ +"""Phase 7.4 — Campaign Engine tests.""" +from __future__ import annotations + +import datetime as dt + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import LoyaltyEventType +from app.tests.conftest import TENANT_A, tenant_headers + + +async def _setup( + client, + *, + suffix: str, + rules: dict | None = None, + starts_on: str | None = None, + ends_on: str | None = None, +) -> dict: + program = await client.post( + "/api/v1/programs", + json={"code": f"cmp{suffix}", "name": f"Campaign {suffix}"}, + headers=tenant_headers(TENANT_A), + ) + assert program.status_code == 201, program.text + program_id = program.json()["id"] + + member = await client.post( + "/api/v1/members", + json={"program_id": program_id, "display_name": "Member", "enroll": True}, + headers=tenant_headers(TENANT_A), + ) + assert member.status_code == 201, member.text + member_id = member.json()["id"] + + account = await client.post( + "/api/v1/point-accounts", + json={"program_id": program_id, "member_id": member_id}, + headers=tenant_headers(TENANT_A), + ) + assert account.status_code == 201, account.text + account_id = account.json()["id"] + + payload = { + "program_id": program_id, + "code": f"camp{suffix}", + "name": f"Campaign {suffix}", + "rules": rules if rules is not None else {"earn_bonus_points": 50}, + } + if starts_on is not None: + payload["starts_on"] = starts_on + if ends_on is not None: + payload["ends_on"] = ends_on + campaign = await client.post( + "/api/v1/campaigns", json=payload, headers=tenant_headers(TENANT_A) + ) + assert campaign.status_code == 201, campaign.text + + return { + "program_id": program_id, + "member_id": member_id, + "account_id": account_id, + "campaign_id": campaign.json()["id"], + } + + +@pytest.mark.asyncio +async def test_full_lifecycle_schedule_activate_pause_resume_expire(client): + ctx = await _setup(client, suffix="l1", starts_on="2026-01-01") + campaign_id = ctx["campaign_id"] + + scheduled = await client.post( + f"/api/v1/campaigns/{campaign_id}/schedule", + headers=tenant_headers(TENANT_A), + ) + assert scheduled.status_code == 200, scheduled.text + assert scheduled.json()["status"] == "scheduled" + + activated = await client.post( + f"/api/v1/campaigns/{campaign_id}/activate", + headers=tenant_headers(TENANT_A), + ) + assert activated.status_code == 200, activated.text + assert activated.json()["status"] == "active" + + paused = await client.post( + f"/api/v1/campaigns/{campaign_id}/pause", + headers=tenant_headers(TENANT_A), + ) + assert paused.status_code == 200, paused.text + assert paused.json()["status"] == "paused" + + resumed = await client.post( + f"/api/v1/campaigns/{campaign_id}/resume", + headers=tenant_headers(TENANT_A), + ) + assert resumed.status_code == 200, resumed.text + assert resumed.json()["status"] == "active" + + expired = await client.post( + f"/api/v1/campaigns/{campaign_id}/expire", + headers=tenant_headers(TENANT_A), + ) + assert expired.status_code == 200, expired.text + assert expired.json()["status"] == "expired" + + events = {e.event_type for e in get_event_publisher().published} + assert LoyaltyEventType.CAMPAIGN_SCHEDULED.value in events + assert LoyaltyEventType.CAMPAIGN_ACTIVATED.value in events + assert LoyaltyEventType.CAMPAIGN_PAUSED.value in events + assert LoyaltyEventType.CAMPAIGN_RESUMED.value in events + assert LoyaltyEventType.CAMPAIGN_EXPIRED.value in events + + +@pytest.mark.asyncio +async def test_schedule_requires_starts_on(client): + ctx = await _setup(client, suffix="l2") + bad = await client.post( + f"/api/v1/campaigns/{ctx['campaign_id']}/schedule", + headers=tenant_headers(TENANT_A), + ) + assert bad.status_code == 422 + assert bad.json()["error"]["code"] == "campaign_starts_on_required" + + +@pytest.mark.asyncio +async def test_invalid_transition_rejected(client): + ctx = await _setup(client, suffix="l3") + # draft campaign cannot pause directly + bad = await client.post( + f"/api/v1/campaigns/{ctx['campaign_id']}/pause", + headers=tenant_headers(TENANT_A), + ) + assert bad.status_code == 409 + assert bad.json()["error"]["code"] == "invalid_campaign_transition" + + +@pytest.mark.asyncio +async def test_active_campaign_cannot_be_soft_deleted(client): + ctx = await _setup(client, suffix="l4") + activated = await client.post( + f"/api/v1/campaigns/{ctx['campaign_id']}/activate", + headers=tenant_headers(TENANT_A), + ) + assert activated.status_code == 200, activated.text + + deleted = await client.post( + f"/api/v1/campaigns/{ctx['campaign_id']}/delete", + headers=tenant_headers(TENANT_A), + ) + assert deleted.status_code == 409 + assert deleted.json()["error"]["code"] == "campaign_active_not_deletable" + + +@pytest.mark.asyncio +async def test_rules_update_bumps_version_and_emits_event(client): + ctx = await _setup(client, suffix="l5") + campaign_id = ctx["campaign_id"] + + updated = await client.post( + f"/api/v1/campaigns/{campaign_id}/rules", + json={"rules": {"earn_bonus_points": 75, "earn_multiplier": 2.0}}, + headers=tenant_headers(TENANT_A), + ) + assert updated.status_code == 200, updated.text + assert updated.json()["rule_version"] == 2 + assert updated.json()["rules"]["earn_bonus_points"] == 75 + + updated_again = await client.post( + f"/api/v1/campaigns/{campaign_id}/rules", + json={"rules": {"earn_bonus_points": 90}}, + headers=tenant_headers(TENANT_A), + ) + assert updated_again.status_code == 200 + assert updated_again.json()["rule_version"] == 3 + + events = {e.event_type for e in get_event_publisher().published} + assert LoyaltyEventType.CAMPAIGN_RULES_UPDATED.value in events + + +@pytest.mark.asyncio +async def test_apply_grants_bonus_points_via_ledger(client): + ctx = await _setup(client, suffix="a1", rules={"earn_bonus_points": 50}) + campaign_id = ctx["campaign_id"] + await client.post( + f"/api/v1/campaigns/{campaign_id}/activate", + headers=tenant_headers(TENANT_A), + ) + + apply_resp = await client.post( + f"/api/v1/campaigns/{campaign_id}/apply", + json={"member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + assert apply_resp.status_code == 201, apply_resp.text + body = apply_resp.json() + assert body["points_granted"] == 50 + assert body["status"] == "applied" + assert body["ledger_entry_id"] is not None + assert body["rule_version"] == 1 + + balance = await client.get( + f"/api/v1/point-accounts/{ctx['account_id']}/balance", + headers=tenant_headers(TENANT_A), + ) + assert balance.json()["balance"] == 50 + + events = {e.event_type for e in get_event_publisher().published} + assert LoyaltyEventType.CAMPAIGN_APPLIED.value in events + assert LoyaltyEventType.POINTS_EARNED.value in events + + +@pytest.mark.asyncio +async def test_apply_with_base_amount_uses_multiplier(client): + ctx = await _setup( + client, + suffix="a2", + rules={"earn_bonus_points": 10, "earn_multiplier": 1.5}, + ) + campaign_id = ctx["campaign_id"] + await client.post( + f"/api/v1/campaigns/{campaign_id}/activate", + headers=tenant_headers(TENANT_A), + ) + + apply_resp = await client.post( + f"/api/v1/campaigns/{campaign_id}/apply", + json={"member_id": ctx["member_id"], "base_amount": 100}, + headers=tenant_headers(TENANT_A), + ) + assert apply_resp.status_code == 201, apply_resp.text + # floor(100 * 1.5) + 10 = 160 + assert apply_resp.json()["points_granted"] == 160 + + +@pytest.mark.asyncio +async def test_apply_rejects_inactive_campaign(client): + ctx = await _setup(client, suffix="a3") + apply_resp = await client.post( + f"/api/v1/campaigns/{ctx['campaign_id']}/apply", + json={"member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + assert apply_resp.status_code == 409 + assert apply_resp.json()["error"]["code"] == "campaign_not_active" + + +@pytest.mark.asyncio +async def test_apply_rejects_before_start_window(client): + future = (dt.date.today() + dt.timedelta(days=30)).isoformat() + ctx = await _setup(client, suffix="a4", starts_on=future) + await client.post( + f"/api/v1/campaigns/{ctx['campaign_id']}/activate", + headers=tenant_headers(TENANT_A), + ) + apply_resp = await client.post( + f"/api/v1/campaigns/{ctx['campaign_id']}/apply", + json={"member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + assert apply_resp.status_code == 409 + assert apply_resp.json()["error"]["code"] == "campaign_not_started" + + +@pytest.mark.asyncio +async def test_apply_rejects_after_end_window(client): + past_start = (dt.date.today() - dt.timedelta(days=60)).isoformat() + past_end = (dt.date.today() - dt.timedelta(days=1)).isoformat() + ctx = await _setup(client, suffix="a5", starts_on=past_start, ends_on=past_end) + await client.post( + f"/api/v1/campaigns/{ctx['campaign_id']}/activate", + headers=tenant_headers(TENANT_A), + ) + apply_resp = await client.post( + f"/api/v1/campaigns/{ctx['campaign_id']}/apply", + json={"member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + assert apply_resp.status_code == 409 + assert apply_resp.json()["error"]["code"] == "campaign_ended" + + +@pytest.mark.asyncio +async def test_apply_respects_max_applications_per_member(client): + ctx = await _setup( + client, + suffix="a6", + rules={"earn_bonus_points": 10, "max_applications_per_member": 1}, + ) + campaign_id = ctx["campaign_id"] + await client.post( + f"/api/v1/campaigns/{campaign_id}/activate", + headers=tenant_headers(TENANT_A), + ) + + first = await client.post( + f"/api/v1/campaigns/{campaign_id}/apply", + json={"member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + assert first.status_code == 201, first.text + + second = await client.post( + f"/api/v1/campaigns/{campaign_id}/apply", + json={"member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + assert second.status_code == 409 + assert second.json()["error"]["code"] == "campaign_max_applications_exceeded" + + +@pytest.mark.asyncio +async def test_apply_idempotency_returns_same_application_and_single_grant(client): + ctx = await _setup(client, suffix="a7", rules={"earn_bonus_points": 20}) + campaign_id = ctx["campaign_id"] + await client.post( + f"/api/v1/campaigns/{campaign_id}/activate", + headers=tenant_headers(TENANT_A), + ) + + first = await client.post( + f"/api/v1/campaigns/{campaign_id}/apply", + json={"member_id": ctx["member_id"], "idempotency_key": "camp-idem-1"}, + headers=tenant_headers(TENANT_A), + ) + assert first.status_code == 201, first.text + + second = await client.post( + f"/api/v1/campaigns/{campaign_id}/apply", + json={"member_id": ctx["member_id"], "idempotency_key": "camp-idem-1"}, + headers=tenant_headers(TENANT_A), + ) + assert second.status_code == 201 + assert second.json()["id"] == first.json()["id"] + + balance = await client.get( + f"/api/v1/point-accounts/{ctx['account_id']}/balance", + headers=tenant_headers(TENANT_A), + ) + assert balance.json()["balance"] == 20 + + +@pytest.mark.asyncio +async def test_apply_zero_grant_rejected(client): + ctx = await _setup(client, suffix="a8", rules={"earn_bonus_points": 0}) + campaign_id = ctx["campaign_id"] + await client.post( + f"/api/v1/campaigns/{campaign_id}/activate", + headers=tenant_headers(TENANT_A), + ) + apply_resp = await client.post( + f"/api/v1/campaigns/{campaign_id}/apply", + json={"member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + assert apply_resp.status_code == 422 + assert apply_resp.json()["error"]["code"] == "campaign_grant_invalid" + + +@pytest.mark.asyncio +async def test_list_campaign_applications(client): + ctx = await _setup(client, suffix="a9", rules={"earn_bonus_points": 15}) + campaign_id = ctx["campaign_id"] + await client.post( + f"/api/v1/campaigns/{campaign_id}/activate", + headers=tenant_headers(TENANT_A), + ) + applied = await client.post( + f"/api/v1/campaigns/{campaign_id}/apply", + json={"member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + assert applied.status_code == 201, applied.text + + listed = await client.get( + f"/api/v1/campaigns/{campaign_id}/applications", + headers=tenant_headers(TENANT_A), + ) + assert listed.status_code == 200, listed.text + assert listed.json()["total"] >= 1 + assert listed.json()["items"][0]["campaign_id"] == campaign_id + + +@pytest.mark.asyncio +async def test_apply_explicit_point_account_id(client): + ctx = await _setup(client, suffix="a10", rules={"earn_bonus_points": 30}) + campaign_id = ctx["campaign_id"] + await client.post( + f"/api/v1/campaigns/{campaign_id}/activate", + headers=tenant_headers(TENANT_A), + ) + apply_resp = await client.post( + f"/api/v1/campaigns/{campaign_id}/apply", + json={ + "member_id": ctx["member_id"], + "point_account_id": ctx["account_id"], + }, + headers=tenant_headers(TENANT_A), + ) + assert apply_resp.status_code == 201, apply_resp.text + assert apply_resp.json()["point_account_id"] == ctx["account_id"] diff --git a/backend/services/loyalty/app/tests/test_phase75.py b/backend/services/loyalty/app/tests/test_phase75.py new file mode 100644 index 0000000..d78ef1a --- /dev/null +++ b/backend/services/loyalty/app/tests/test_phase75.py @@ -0,0 +1,468 @@ +"""Phase 7.5 — Referral Engine tests.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import LoyaltyEventType +from app.tests.conftest import TENANT_A, tenant_headers + + +async def _setup( + client, + *, + suffix: str, + referrer_bonus: int = 50, + referee_bonus: int = 20, + max_referrals: int | None = None, + enroll_referee: bool = False, +) -> dict: + program = await client.post( + "/api/v1/programs", + json={"code": f"ref{suffix}", "name": f"Referral {suffix}"}, + headers=tenant_headers(TENANT_A), + ) + assert program.status_code == 201, program.text + program_id = program.json()["id"] + + referrer = await client.post( + "/api/v1/members", + json={"program_id": program_id, "display_name": "Referrer", "enroll": True}, + headers=tenant_headers(TENANT_A), + ) + assert referrer.status_code == 201, referrer.text + referrer_id = referrer.json()["id"] + + referrer_account = await client.post( + "/api/v1/point-accounts", + json={"program_id": program_id, "member_id": referrer_id}, + headers=tenant_headers(TENANT_A), + ) + assert referrer_account.status_code == 201, referrer_account.text + referrer_account_id = referrer_account.json()["id"] + + referee = await client.post( + "/api/v1/members", + json={ + "program_id": program_id, + "display_name": "Referee", + "enroll": enroll_referee, + }, + headers=tenant_headers(TENANT_A), + ) + assert referee.status_code == 201, referee.text + referee_id = referee.json()["id"] + + referee_account = await client.post( + "/api/v1/point-accounts", + json={"program_id": program_id, "member_id": referee_id}, + headers=tenant_headers(TENANT_A), + ) + assert referee_account.status_code == 201, referee_account.text + referee_account_id = referee_account.json()["id"] + + referral_program = await client.post( + "/api/v1/referral-programs", + json={ + "program_id": program_id, + "code": f"rp{suffix}", + "name": f"Referral Program {suffix}", + "status": "active", + "referrer_bonus_points": referrer_bonus, + "referee_bonus_points": referee_bonus, + "max_referrals_per_member": max_referrals, + }, + headers=tenant_headers(TENANT_A), + ) + assert referral_program.status_code == 201, referral_program.text + referral_program_id = referral_program.json()["id"] + + code_resp = await client.post( + "/api/v1/referral-codes", + json={"referral_program_id": referral_program_id, "member_id": referrer_id}, + headers=tenant_headers(TENANT_A), + ) + assert code_resp.status_code == 201, code_resp.text + + return { + "program_id": program_id, + "referral_program_id": referral_program_id, + "referrer_id": referrer_id, + "referrer_account_id": referrer_account_id, + "referee_id": referee_id, + "referee_account_id": referee_account_id, + "code": code_resp.json()["code"], + "code_id": code_resp.json()["id"], + } + + +async def _attribute(client, ctx, **overrides): + payload = {"code": ctx["code"], "referee_member_id": ctx["referee_id"]} + payload.update(overrides) + return await client.post( + "/api/v1/referrals/attribute", json=payload, headers=tenant_headers(TENANT_A) + ) + + +@pytest.mark.asyncio +async def test_referral_program_crud(client): + program = await client.post( + "/api/v1/programs", + json={"code": "refcrud", "name": "Referral CRUD Program"}, + headers=tenant_headers(TENANT_A), + ) + program_id = program.json()["id"] + + created = await client.post( + "/api/v1/referral-programs", + json={ + "program_id": program_id, + "code": "rpcrud", + "name": "CRUD", + "referrer_bonus_points": 10, + "referee_bonus_points": 5, + }, + headers=tenant_headers(TENANT_A), + ) + assert created.status_code == 201, created.text + assert created.json()["status"] == "draft" + rp_id = created.json()["id"] + + listed = await client.get( + "/api/v1/referral-programs", headers=tenant_headers(TENANT_A) + ) + assert listed.status_code == 200 + assert any(p["id"] == rp_id for p in listed.json()) + + fetched = await client.get( + f"/api/v1/referral-programs/{rp_id}", headers=tenant_headers(TENANT_A) + ) + assert fetched.status_code == 200 + assert fetched.json()["code"] == "RPCRUD" + + updated = await client.patch( + f"/api/v1/referral-programs/{rp_id}", + json={"status": "active", "referrer_bonus_points": 25}, + headers=tenant_headers(TENANT_A), + ) + assert updated.status_code == 200, updated.text + assert updated.json()["status"] == "active" + assert updated.json()["referrer_bonus_points"] == 25 + + deleted = await client.post( + f"/api/v1/referral-programs/{rp_id}/delete", headers=tenant_headers(TENANT_A) + ) + assert deleted.status_code == 200 + assert deleted.json()["is_deleted"] is True + + events = {e.event_type for e in get_event_publisher().published} + assert LoyaltyEventType.REFERRAL_PROGRAM_CREATED.value in events + assert LoyaltyEventType.REFERRAL_PROGRAM_UPDATED.value in events + assert LoyaltyEventType.REFERRAL_PROGRAM_DELETED.value in events + + +@pytest.mark.asyncio +async def test_issue_code_returns_existing_active_code(client): + ctx = await _setup(client, suffix="ic1") + + second = await client.post( + "/api/v1/referral-codes", + json={ + "referral_program_id": ctx["referral_program_id"], + "member_id": ctx["referrer_id"], + }, + headers=tenant_headers(TENANT_A), + ) + assert second.status_code == 201, second.text + assert second.json()["id"] == ctx["code_id"] + + events = {e.event_type for e in get_event_publisher().published} + assert LoyaltyEventType.REFERRAL_CODE_ISSUED.value in events + + +@pytest.mark.asyncio +async def test_full_flow_attribute_convert_reward_with_ledger_balances(client): + ctx = await _setup( + client, suffix="h1", referrer_bonus=50, referee_bonus=20, enroll_referee=False + ) + + attributed = await _attribute(client, ctx) + assert attributed.status_code == 201, attributed.text + body = attributed.json() + assert body["status"] == "pending" + attribution_id = body["id"] + + # Referee must be active before conversion. + enroll = await client.post( + f"/api/v1/members/{ctx['referee_id']}/enroll", + json={}, + headers=tenant_headers(TENANT_A), + ) + assert enroll.status_code == 200, enroll.text + + converted = await client.post( + f"/api/v1/referrals/{attribution_id}/convert", + headers=tenant_headers(TENANT_A), + ) + assert converted.status_code == 200, converted.text + assert converted.json()["status"] == "converted" + assert converted.json()["converted_at"] is not None + + rewarded = await client.post( + f"/api/v1/referrals/{attribution_id}/reward", + headers=tenant_headers(TENANT_A), + ) + assert rewarded.status_code == 200, rewarded.text + reward_body = rewarded.json() + assert reward_body["status"] == "rewarded" + assert reward_body["referrer_ledger_entry_id"] is not None + assert reward_body["referee_ledger_entry_id"] is not None + + referrer_balance = await client.get( + f"/api/v1/point-accounts/{ctx['referrer_account_id']}/balance", + headers=tenant_headers(TENANT_A), + ) + assert referrer_balance.json()["balance"] == 50 + + referee_balance = await client.get( + f"/api/v1/point-accounts/{ctx['referee_account_id']}/balance", + headers=tenant_headers(TENANT_A), + ) + assert referee_balance.json()["balance"] == 20 + + events = {e.event_type for e in get_event_publisher().published} + assert LoyaltyEventType.REFERRAL_CODE_ISSUED.value in events + assert LoyaltyEventType.REFERRAL_ATTRIBUTED.value in events + assert LoyaltyEventType.REFERRAL_CONVERTED.value in events + assert LoyaltyEventType.REFERRAL_REWARDED.value in events + assert LoyaltyEventType.POINTS_EARNED.value in events + + +@pytest.mark.asyncio +async def test_self_referral_rejected(client): + ctx = await _setup(client, suffix="sr1") + resp = await _attribute(client, ctx, referee_member_id=ctx["referrer_id"]) + assert resp.status_code == 422 + assert resp.json()["error"]["code"] == "referral_self_referral_forbidden" + + +@pytest.mark.asyncio +async def test_referee_already_attributed_rejected(client): + ctx = await _setup(client, suffix="dup1") + first = await _attribute(client, ctx) + assert first.status_code == 201, first.text + + second = await _attribute(client, ctx) + assert second.status_code == 409 + assert second.json()["error"]["code"] == "referral_referee_already_attributed" + + +@pytest.mark.asyncio +async def test_max_referrals_per_member_enforced(client): + ctx = await _setup(client, suffix="max1", max_referrals=1) + + first = await _attribute(client, ctx) + assert first.status_code == 201, first.text + + # New referee for the same referrer's code. + referee2 = await client.post( + "/api/v1/members", + json={ + "program_id": ctx["program_id"], + "display_name": "Referee 2", + "enroll": True, + }, + headers=tenant_headers(TENANT_A), + ) + assert referee2.status_code == 201, referee2.text + + second = await _attribute(client, ctx, referee_member_id=referee2.json()["id"]) + assert second.status_code == 409 + assert second.json()["error"]["code"] == "referral_max_referrals_exceeded" + + +@pytest.mark.asyncio +async def test_convert_requires_pending_status(client): + ctx = await _setup(client, suffix="cv1", enroll_referee=True) + attributed = await _attribute(client, ctx) + attribution_id = attributed.json()["id"] + + first_convert = await client.post( + f"/api/v1/referrals/{attribution_id}/convert", + headers=tenant_headers(TENANT_A), + ) + assert first_convert.status_code == 200, first_convert.text + + second_convert = await client.post( + f"/api/v1/referrals/{attribution_id}/convert", + headers=tenant_headers(TENANT_A), + ) + assert second_convert.status_code == 409 + assert second_convert.json()["error"]["code"] == "referral_not_pending" + + +@pytest.mark.asyncio +async def test_convert_requires_referee_active(client): + ctx = await _setup(client, suffix="cv2", enroll_referee=False) + attributed = await _attribute(client, ctx) + attribution_id = attributed.json()["id"] + + resp = await client.post( + f"/api/v1/referrals/{attribution_id}/convert", + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 409 + assert resp.json()["error"]["code"] == "referral_referee_not_active" + + +@pytest.mark.asyncio +async def test_reward_requires_converted_status(client): + ctx = await _setup(client, suffix="rw1", enroll_referee=True) + attributed = await _attribute(client, ctx) + attribution_id = attributed.json()["id"] + + resp = await client.post( + f"/api/v1/referrals/{attribution_id}/reward", + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 409 + assert resp.json()["error"]["code"] == "referral_not_converted" + + +@pytest.mark.asyncio +async def test_double_reward_rejected(client): + ctx = await _setup(client, suffix="dr1", enroll_referee=True) + attributed = await _attribute(client, ctx) + attribution_id = attributed.json()["id"] + + await client.post( + f"/api/v1/referrals/{attribution_id}/convert", + headers=tenant_headers(TENANT_A), + ) + first_reward = await client.post( + f"/api/v1/referrals/{attribution_id}/reward", + headers=tenant_headers(TENANT_A), + ) + assert first_reward.status_code == 200, first_reward.text + + second_reward = await client.post( + f"/api/v1/referrals/{attribution_id}/reward", + headers=tenant_headers(TENANT_A), + ) + assert second_reward.status_code == 409 + assert second_reward.json()["error"]["code"] == "referral_not_converted" + + referrer_balance = await client.get( + f"/api/v1/point-accounts/{ctx['referrer_account_id']}/balance", + headers=tenant_headers(TENANT_A), + ) + assert referrer_balance.json()["balance"] == 50 + + +@pytest.mark.asyncio +async def test_attribute_idempotency_returns_same_attribution(client): + ctx = await _setup(client, suffix="idem1") + + first = await _attribute(client, ctx, idempotency_key="ref-idem-1") + assert first.status_code == 201, first.text + + second = await _attribute(client, ctx, idempotency_key="ref-idem-1") + assert second.status_code == 201, second.text + assert second.json()["id"] == first.json()["id"] + + +@pytest.mark.asyncio +async def test_cancel_pending_attribution(client): + ctx = await _setup(client, suffix="cn1") + attributed = await _attribute(client, ctx) + attribution_id = attributed.json()["id"] + + cancelled = await client.post( + f"/api/v1/referrals/{attribution_id}/cancel", + json={"reason": "duplicate signup"}, + headers=tenant_headers(TENANT_A), + ) + assert cancelled.status_code == 200, cancelled.text + assert cancelled.json()["status"] == "cancelled" + assert cancelled.json()["cancel_reason"] == "duplicate signup" + + # Cancelling again should be rejected. + again = await client.post( + f"/api/v1/referrals/{attribution_id}/cancel", + json={}, + headers=tenant_headers(TENANT_A), + ) + assert again.status_code == 409 + assert again.json()["error"]["code"] == "referral_not_cancellable" + + events = {e.event_type for e in get_event_publisher().published} + assert LoyaltyEventType.REFERRAL_CANCELLED.value in events + + +@pytest.mark.asyncio +async def test_rewarded_attribution_cannot_be_cancelled(client): + ctx = await _setup(client, suffix="cn2", enroll_referee=True) + attributed = await _attribute(client, ctx) + attribution_id = attributed.json()["id"] + await client.post( + f"/api/v1/referrals/{attribution_id}/convert", + headers=tenant_headers(TENANT_A), + ) + await client.post( + f"/api/v1/referrals/{attribution_id}/reward", + headers=tenant_headers(TENANT_A), + ) + + resp = await client.post( + f"/api/v1/referrals/{attribution_id}/cancel", + json={}, + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 409 + assert resp.json()["error"]["code"] == "referral_not_cancellable" + + +@pytest.mark.asyncio +async def test_list_referral_attributions(client): + ctx = await _setup(client, suffix="ls1") + attributed = await _attribute(client, ctx) + assert attributed.status_code == 201, attributed.text + + listed = await client.get( + "/api/v1/referrals", + params={"referral_program_id": ctx["referral_program_id"]}, + headers=tenant_headers(TENANT_A), + ) + assert listed.status_code == 200, listed.text + assert listed.json()["total"] >= 1 + assert listed.json()["items"][0]["referral_program_id"] == ctx["referral_program_id"] + + +@pytest.mark.asyncio +async def test_attribute_rejects_inactive_referral_program(client): + ctx = await _setup(client, suffix="ia1") + deactivated = await client.patch( + f"/api/v1/referral-programs/{ctx['referral_program_id']}", + json={"status": "inactive"}, + headers=tenant_headers(TENANT_A), + ) + assert deactivated.status_code == 200, deactivated.text + + resp = await _attribute(client, ctx) + assert resp.status_code == 409 + assert resp.json()["error"]["code"] == "referral_program_not_active" + + +@pytest.mark.asyncio +async def test_revoked_code_cannot_be_used(client): + ctx = await _setup(client, suffix="rc1") + revoked = await client.post( + f"/api/v1/referral-codes/{ctx['code_id']}/revoke", + headers=tenant_headers(TENANT_A), + ) + assert revoked.status_code == 200 + assert revoked.json()["status"] == "revoked" + + resp = await _attribute(client, ctx) + assert resp.status_code == 409 + assert resp.json()["error"]["code"] == "referral_code_not_active" diff --git a/backend/services/loyalty/app/tests/test_phase76.py b/backend/services/loyalty/app/tests/test_phase76.py new file mode 100644 index 0000000..1456d58 --- /dev/null +++ b/backend/services/loyalty/app/tests/test_phase76.py @@ -0,0 +1,339 @@ +"""Phase 7.6 — Wallet Engine tests.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import LoyaltyEventType +from app.tests.conftest import TENANT_A, tenant_headers + + +async def _open_wallet(client, *, suffix: str, currency: str = "IRR") -> dict: + program = await client.post( + "/api/v1/programs", + json={"code": f"wal{suffix}", "name": f"Wallet {suffix}"}, + headers=tenant_headers(TENANT_A), + ) + assert program.status_code == 201, program.text + program_id = program.json()["id"] + + member = await client.post( + "/api/v1/members", + json={"program_id": program_id, "display_name": f"Member {suffix}", "enroll": True}, + headers=tenant_headers(TENANT_A), + ) + assert member.status_code == 201, member.text + member_id = member.json()["id"] + + wallet = await client.post( + "/api/v1/wallets", + json={ + "program_id": program_id, + "member_id": member_id, + "currency_code": currency, + }, + headers=tenant_headers(TENANT_A), + ) + assert wallet.status_code == 201, wallet.text + return { + "program_id": program_id, + "member_id": member_id, + "wallet_id": wallet.json()["id"], + "account_number": wallet.json()["account_number"], + } + + +@pytest.mark.asyncio +async def test_open_credit_and_balance(client): + ctx = await _open_wallet(client, suffix="oc1") + + credit = await client.post( + f"/api/v1/wallets/{ctx['wallet_id']}/credit", + json={"amount": 1000, "reason": "top-up", "idempotency_key": "c1"}, + headers=tenant_headers(TENANT_A), + ) + assert credit.status_code == 200, credit.text + assert credit.json()["balance_after"] == 1000 + assert credit.json()["entry_type"] == "credit" + + again = await client.post( + f"/api/v1/wallets/{ctx['wallet_id']}/credit", + json={"amount": 1000, "reason": "top-up", "idempotency_key": "c1"}, + headers=tenant_headers(TENANT_A), + ) + assert again.status_code == 200 + assert again.json()["id"] == credit.json()["id"] + + balance = await client.get( + f"/api/v1/wallets/{ctx['wallet_id']}/balance", + headers=tenant_headers(TENANT_A), + ) + assert balance.status_code == 200 + assert balance.json()["balance"] == 1000 + assert balance.json()["currency_code"] == "IRR" + + events = {e.event_type for e in get_event_publisher().published} + assert LoyaltyEventType.WALLET_OPENED.value in events + assert LoyaltyEventType.WALLET_CREDITED.value in events + + +@pytest.mark.asyncio +async def test_debit_insufficient_funds_rejected(client): + ctx = await _open_wallet(client, suffix="db1") + await client.post( + f"/api/v1/wallets/{ctx['wallet_id']}/credit", + json={"amount": 200}, + headers=tenant_headers(TENANT_A), + ) + + debit = await client.post( + f"/api/v1/wallets/{ctx['wallet_id']}/debit", + json={"amount": 50, "reason": "purchase"}, + headers=tenant_headers(TENANT_A), + ) + assert debit.status_code == 200, debit.text + assert debit.json()["balance_after"] == 150 + + over = await client.post( + f"/api/v1/wallets/{ctx['wallet_id']}/debit", + json={"amount": 1000}, + headers=tenant_headers(TENANT_A), + ) + assert over.status_code == 409 + assert over.json()["error"]["code"] == "wallet_insufficient_funds" + + events = {e.event_type for e in get_event_publisher().published} + assert LoyaltyEventType.WALLET_DEBITED.value in events + + +@pytest.mark.asyncio +async def test_adjust_and_ledger_list(client): + ctx = await _open_wallet(client, suffix="adj1") + await client.post( + f"/api/v1/wallets/{ctx['wallet_id']}/credit", + json={"amount": 500}, + headers=tenant_headers(TENANT_A), + ) + adjust = await client.post( + f"/api/v1/wallets/{ctx['wallet_id']}/adjust", + json={"amount": -50, "reason": "correction"}, + headers=tenant_headers(TENANT_A), + ) + assert adjust.status_code == 200, adjust.text + assert adjust.json()["balance_after"] == 450 + + zero_adjust = await client.post( + f"/api/v1/wallets/{ctx['wallet_id']}/adjust", + json={"amount": 0, "reason": "noop"}, + headers=tenant_headers(TENANT_A), + ) + assert zero_adjust.status_code == 422 + assert zero_adjust.json()["error"]["code"] == "invalid_wallet_adjust_amount" + + page = await client.get( + f"/api/v1/wallets/{ctx['wallet_id']}/ledger", + params={"limit": 10}, + headers=tenant_headers(TENANT_A), + ) + assert page.status_code == 200 + body = page.json() + assert body["total"] >= 2 + assert len(body["items"]) >= 2 + + events = {e.event_type for e in get_event_publisher().published} + assert LoyaltyEventType.WALLET_ADJUSTED.value in events + + +@pytest.mark.asyncio +async def test_transfer_between_wallets_same_currency(client): + source = await _open_wallet(client, suffix="tr1a") + target = await _open_wallet(client, suffix="tr1b") + + await client.post( + f"/api/v1/wallets/{source['wallet_id']}/credit", + json={"amount": 300}, + headers=tenant_headers(TENANT_A), + ) + + transfer = await client.post( + f"/api/v1/wallets/{source['wallet_id']}/transfer", + json={"target_wallet_id": target["wallet_id"], "amount": 120, "reason": "p2p"}, + headers=tenant_headers(TENANT_A), + ) + assert transfer.status_code == 200, transfer.text + body = transfer.json() + assert body["source_entry"]["entry_type"] == "transfer_out" + assert body["source_entry"]["amount"] == -120 + assert body["source_entry"]["balance_after"] == 180 + assert body["target_entry"]["entry_type"] == "transfer_in" + assert body["target_entry"]["amount"] == 120 + assert body["target_entry"]["balance_after"] == 120 + + source_balance = await client.get( + f"/api/v1/wallets/{source['wallet_id']}/balance", + headers=tenant_headers(TENANT_A), + ) + assert source_balance.json()["balance"] == 180 + + target_balance = await client.get( + f"/api/v1/wallets/{target['wallet_id']}/balance", + headers=tenant_headers(TENANT_A), + ) + assert target_balance.json()["balance"] == 120 + + events = {e.event_type for e in get_event_publisher().published} + assert LoyaltyEventType.WALLET_TRANSFERRED.value in events + + +@pytest.mark.asyncio +async def test_transfer_rejects_different_currency(client): + source = await _open_wallet(client, suffix="cur1a", currency="IRR") + target = await _open_wallet(client, suffix="cur1b", currency="USD") + await client.post( + f"/api/v1/wallets/{source['wallet_id']}/credit", + json={"amount": 500}, + headers=tenant_headers(TENANT_A), + ) + + resp = await client.post( + f"/api/v1/wallets/{source['wallet_id']}/transfer", + json={"target_wallet_id": target["wallet_id"], "amount": 10}, + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 422 + assert resp.json()["error"]["code"] == "wallet_currency_mismatch" + + +@pytest.mark.asyncio +async def test_transfer_rejects_same_wallet(client): + ctx = await _open_wallet(client, suffix="same1") + await client.post( + f"/api/v1/wallets/{ctx['wallet_id']}/credit", + json={"amount": 500}, + headers=tenant_headers(TENANT_A), + ) + + resp = await client.post( + f"/api/v1/wallets/{ctx['wallet_id']}/transfer", + json={"target_wallet_id": ctx["wallet_id"], "amount": 10}, + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 422 + assert resp.json()["error"]["code"] == "wallet_transfer_same_account" + + +@pytest.mark.asyncio +async def test_transfer_idempotency_returns_same_entries(client): + source = await _open_wallet(client, suffix="idem1a") + target = await _open_wallet(client, suffix="idem1b") + await client.post( + f"/api/v1/wallets/{source['wallet_id']}/credit", + json={"amount": 500}, + headers=tenant_headers(TENANT_A), + ) + + first = await client.post( + f"/api/v1/wallets/{source['wallet_id']}/transfer", + json={ + "target_wallet_id": target["wallet_id"], + "amount": 75, + "idempotency_key": "tr-idem-1", + }, + headers=tenant_headers(TENANT_A), + ) + assert first.status_code == 200, first.text + + second = await client.post( + f"/api/v1/wallets/{source['wallet_id']}/transfer", + json={ + "target_wallet_id": target["wallet_id"], + "amount": 75, + "idempotency_key": "tr-idem-1", + }, + headers=tenant_headers(TENANT_A), + ) + assert second.status_code == 200, second.text + assert second.json()["source_entry"]["id"] == first.json()["source_entry"]["id"] + assert second.json()["target_entry"]["id"] == first.json()["target_entry"]["id"] + + balance = await client.get( + f"/api/v1/wallets/{source['wallet_id']}/balance", + headers=tenant_headers(TENANT_A), + ) + assert balance.json()["balance"] == 425 + + +@pytest.mark.asyncio +async def test_frozen_wallet_rejects_mutations(client): + ctx = await _open_wallet(client, suffix="frz1") + await client.post( + f"/api/v1/wallets/{ctx['wallet_id']}/credit", + json={"amount": 200}, + headers=tenant_headers(TENANT_A), + ) + + frozen = await client.patch( + f"/api/v1/wallets/{ctx['wallet_id']}", + json={"status": "frozen"}, + headers=tenant_headers(TENANT_A), + ) + assert frozen.status_code == 200, frozen.text + assert frozen.json()["status"] == "frozen" + + resp = await client.post( + f"/api/v1/wallets/{ctx['wallet_id']}/credit", + json={"amount": 10}, + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 409 + assert resp.json()["error"]["code"] == "wallet_account_frozen" + + +@pytest.mark.asyncio +async def test_closed_wallet_rejects_mutations(client): + ctx = await _open_wallet(client, suffix="cls1") + await client.post( + f"/api/v1/wallets/{ctx['wallet_id']}/credit", + json={"amount": 200}, + headers=tenant_headers(TENANT_A), + ) + + closed = await client.post( + f"/api/v1/wallets/{ctx['wallet_id']}/delete", + headers=tenant_headers(TENANT_A), + ) + assert closed.status_code == 200, closed.text + assert closed.json()["status"] == "closed" + assert closed.json()["is_deleted"] is True + + resp = await client.post( + f"/api/v1/wallets/{ctx['wallet_id']}/debit", + json={"amount": 10}, + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 404 + + events = {e.event_type for e in get_event_publisher().published} + assert LoyaltyEventType.WALLET_CLOSED.value in events + + +@pytest.mark.asyncio +async def test_duplicate_wallet_for_member_program_rejected(client): + ctx = await _open_wallet(client, suffix="dup1") + + dup = await client.post( + "/api/v1/wallets", + json={"program_id": ctx["program_id"], "member_id": ctx["member_id"]}, + headers=tenant_headers(TENANT_A), + ) + assert dup.status_code == 409 + assert dup.json()["error"]["code"] == "wallet_account_exists" + + +@pytest.mark.asyncio +async def test_list_wallets(client): + ctx = await _open_wallet(client, suffix="ls1") + + listed = await client.get("/api/v1/wallets", headers=tenant_headers(TENANT_A)) + assert listed.status_code == 200 + assert any(w["id"] == ctx["wallet_id"] for w in listed.json()) diff --git a/backend/services/loyalty/app/validators/__init__.py b/backend/services/loyalty/app/validators/__init__.py index a9e7348..d2de878 100644 --- a/backend/services/loyalty/app/validators/__init__.py +++ b/backend/services/loyalty/app/validators/__init__.py @@ -26,6 +26,34 @@ from app.validators.membership import ( target_status_for, validate_reason, ) +from app.validators.rewards import ( + ensure_cancellable, + ensure_fulfillable, + ensure_max_per_member, + ensure_member_active, + ensure_reward_active, + ensure_stock_available, +) +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 app.validators.referral import ( + ensure_attribution_cancellable, + ensure_attribution_converted, + ensure_attribution_pending, + ensure_code_active, + ensure_max_referrals_not_exceeded, + ensure_not_self_referral, + ensure_referral_program_active, + validate_referral_program_status, +) __all__ = [ "validate_non_empty", @@ -51,4 +79,26 @@ __all__ = [ "validate_reason", "DEFAULT_TERM_DAYS", "ACTIVE_MEMBER_STATUSES", + "ensure_reward_active", + "ensure_stock_available", + "ensure_max_per_member", + "ensure_member_active", + "ensure_fulfillable", + "ensure_cancellable", + "ensure_campaign_transition", + "target_campaign_status", + "ensure_schedule_requires_start_date", + "ensure_campaign_active", + "ensure_campaign_window", + "ensure_max_applications_per_member", + "compute_grant_amount", + "ensure_positive_grant", + "validate_referral_program_status", + "ensure_referral_program_active", + "ensure_code_active", + "ensure_not_self_referral", + "ensure_max_referrals_not_exceeded", + "ensure_attribution_pending", + "ensure_attribution_converted", + "ensure_attribution_cancellable", ] diff --git a/backend/services/loyalty/app/validators/campaigns.py b/backend/services/loyalty/app/validators/campaigns.py new file mode 100644 index 0000000..0829678 --- /dev/null +++ b/backend/services/loyalty/app/validators/campaigns.py @@ -0,0 +1,122 @@ +"""Phase 7.4 Campaign Engine validators — lifecycle transitions, window, and grant math.""" +from __future__ import annotations + +import math +from datetime import date + +from shared.exceptions import AppError + +from app.models.types import CampaignStatus + +# Canonical transitions for the Campaign Engine lifecycle actions. +ALLOWED_TRANSITIONS: dict[str, set[CampaignStatus]] = { + "schedule": {CampaignStatus.DRAFT}, + "activate": { + CampaignStatus.DRAFT, + CampaignStatus.SCHEDULED, + CampaignStatus.PAUSED, + }, + "pause": {CampaignStatus.ACTIVE}, + "resume": {CampaignStatus.PAUSED}, + "expire": { + CampaignStatus.ACTIVE, + CampaignStatus.PAUSED, + CampaignStatus.SCHEDULED, + }, +} + +TARGET_STATUS: dict[str, CampaignStatus] = { + "schedule": CampaignStatus.SCHEDULED, + "activate": CampaignStatus.ACTIVE, + "pause": CampaignStatus.PAUSED, + "resume": CampaignStatus.ACTIVE, + "expire": CampaignStatus.EXPIRED, +} + + +def ensure_campaign_transition(*, action: str, current: CampaignStatus) -> None: + allowed = ALLOWED_TRANSITIONS.get(action, set()) + if current not in allowed: + raise AppError( + "انتقال وضعیت کمپین مجاز نیست", + status_code=409, + error_code="invalid_campaign_transition", + details={ + "from_status": current.value, + "action": action, + "allowed_from": sorted(s.value for s in allowed), + }, + ) + + +def target_campaign_status(action: str) -> CampaignStatus: + return TARGET_STATUS[action] + + +def ensure_schedule_requires_start_date(starts_on: date | None) -> None: + if starts_on is None: + raise AppError( + "برای زمان‌بندی کمپین، تاریخ شروع الزامی است", + status_code=422, + error_code="campaign_starts_on_required", + ) + + +def ensure_campaign_active(status: CampaignStatus) -> None: + if status != CampaignStatus.ACTIVE: + raise AppError( + "کمپین فعال نیست", + status_code=409, + error_code="campaign_not_active", + ) + + +def ensure_campaign_window( + starts_on: date | None, ends_on: date | None, *, today: date +) -> None: + if starts_on is not None and today < starts_on: + raise AppError( + "کمپین هنوز شروع نشده است", + status_code=409, + error_code="campaign_not_started", + ) + if ends_on is not None and today > ends_on: + raise AppError( + "کمپین به پایان رسیده است", + status_code=409, + error_code="campaign_ended", + ) + + +def ensure_max_applications_per_member( + current_count: int, max_applications: int | None +) -> None: + if max_applications is not None and current_count >= max_applications: + raise AppError( + "سقف اعمال این کمپین برای این عضو تکمیل شده است", + status_code=409, + error_code="campaign_max_applications_exceeded", + ) + + +def compute_grant_amount(rules: dict | None, base_amount: int | None) -> int: + rules = rules or {} + bonus = int(rules.get("earn_bonus_points", 0) or 0) + if bonus < 0: + bonus = 0 + if base_amount is not None: + multiplier = float(rules.get("earn_multiplier", 1.0) or 1.0) + grant = math.floor(base_amount * multiplier) + bonus + else: + grant = bonus + return max(0, int(grant)) + + +def ensure_positive_grant(grant: int) -> int: + if grant <= 0: + raise AppError( + "مقدار امتیاز قابل اعطا باید بزرگ‌تر از صفر باشد", + status_code=422, + error_code="campaign_grant_invalid", + ) + return grant diff --git a/backend/services/loyalty/app/validators/ledger.py b/backend/services/loyalty/app/validators/ledger.py new file mode 100644 index 0000000..45b79d4 --- /dev/null +++ b/backend/services/loyalty/app/validators/ledger.py @@ -0,0 +1,51 @@ +"""Point ledger validators — Phase 7.2.""" +from __future__ import annotations + +from shared.exceptions import AppError + +from app.models.types import LedgerEntryType, PointAccountStatus + + +def ensure_account_open(status: PointAccountStatus) -> None: + if status == PointAccountStatus.CLOSED: + raise AppError( + "حساب امتیاز بسته است", + status_code=409, + error_code="point_account_closed", + ) + if status == PointAccountStatus.FROZEN: + raise AppError( + "حساب امتیاز مسدود است", + status_code=409, + error_code="point_account_frozen", + ) + + +def validate_positive_points(amount: int, *, field: str = "amount") -> int: + if amount is None or amount <= 0: + raise AppError( + f"{field} باید بزرگ‌تر از صفر باشد", + status_code=422, + error_code="invalid_points_amount", + details={"field": field}, + ) + return amount + + +def validate_adjust_amount(amount: int) -> int: + if amount == 0: + raise AppError( + "مقدار تعدیل نمی‌تواند صفر باشد", + status_code=422, + error_code="invalid_adjust_amount", + ) + return amount + + +def signed_amount(entry_type: LedgerEntryType, amount: int) -> int: + if entry_type in {LedgerEntryType.EARN}: + return abs(amount) + if entry_type in {LedgerEntryType.REDEEM, LedgerEntryType.EXPIRE}: + return -abs(amount) + # ADJUST: caller supplies signed amount + return amount diff --git a/backend/services/loyalty/app/validators/referral.py b/backend/services/loyalty/app/validators/referral.py new file mode 100644 index 0000000..4e3ff38 --- /dev/null +++ b/backend/services/loyalty/app/validators/referral.py @@ -0,0 +1,95 @@ +"""Phase 7.5 Referral Engine validators — program status, attribution rules, transitions.""" +from __future__ import annotations + +from uuid import UUID + +from shared.exceptions import AppError + +from app.models.types import ( + ReferralAttributionStatus, + ReferralCodeStatus, + ReferralProgramStatus, +) + + +def validate_referral_program_status( + status: ReferralProgramStatus | str, +) -> ReferralProgramStatus: + if isinstance(status, ReferralProgramStatus): + return status + try: + return ReferralProgramStatus(status) + except ValueError as exc: + raise AppError( + "وضعیت برنامه معرفی نامعتبر است", + status_code=422, + error_code="invalid_referral_program_status", + ) from exc + + +def ensure_referral_program_active(status: ReferralProgramStatus) -> None: + if status != ReferralProgramStatus.ACTIVE: + raise AppError( + "برنامه معرفی فعال نیست", + status_code=409, + error_code="referral_program_not_active", + ) + + +def ensure_code_active(status: ReferralCodeStatus) -> None: + if status != ReferralCodeStatus.ACTIVE: + raise AppError( + "کد معرفی فعال نیست", + status_code=409, + error_code="referral_code_not_active", + ) + + +def ensure_not_self_referral(referrer_member_id: UUID, referee_member_id: UUID) -> None: + if referrer_member_id == referee_member_id: + raise AppError( + "معرفی خود فرد مجاز نیست", + status_code=422, + error_code="referral_self_referral_forbidden", + ) + + +def ensure_max_referrals_not_exceeded( + current_count: int, max_referrals: int | None +) -> None: + if max_referrals is not None and current_count >= max_referrals: + raise AppError( + "سقف تعداد معرفی‌های این عضو تکمیل شده است", + status_code=409, + error_code="referral_max_referrals_exceeded", + ) + + +def ensure_attribution_pending(status: ReferralAttributionStatus) -> None: + if status != ReferralAttributionStatus.PENDING: + raise AppError( + "وضعیت معرفی برای تبدیل مناسب نیست", + status_code=409, + error_code="referral_not_pending", + ) + + +def ensure_attribution_converted(status: ReferralAttributionStatus) -> None: + if status != ReferralAttributionStatus.CONVERTED: + raise AppError( + "معرفی باید ابتدا تبدیل شده باشد", + status_code=409, + error_code="referral_not_converted", + ) + + +def ensure_attribution_cancellable(status: ReferralAttributionStatus) -> None: + if status in ( + ReferralAttributionStatus.REWARDED, + ReferralAttributionStatus.CANCELLED, + ): + raise AppError( + "این معرفی قابل لغو نیست", + status_code=409, + error_code="referral_not_cancellable", + ) diff --git a/backend/services/loyalty/app/validators/rewards.py b/backend/services/loyalty/app/validators/rewards.py new file mode 100644 index 0000000..3ad9df6 --- /dev/null +++ b/backend/services/loyalty/app/validators/rewards.py @@ -0,0 +1,61 @@ +"""Reward redemption validators — Phase 7.3.""" +from __future__ import annotations + +from shared.exceptions import AppError + +from app.models.foundation import Reward +from app.models.types import MemberStatus, RedemptionStatus, RewardStatus + + +def ensure_reward_active(reward: Reward) -> None: + if reward.is_deleted or reward.status != RewardStatus.ACTIVE: + raise AppError( + "پاداش فعال نیست", + status_code=409, + error_code="reward_not_active", + ) + + +def ensure_stock_available(reward: Reward) -> None: + if reward.stock_limit is not None and reward.redeemed_count >= reward.stock_limit: + raise AppError( + "موجودی پاداش تمام شده است", + status_code=409, + error_code="reward_stock_exhausted", + ) + + +def ensure_max_per_member(current_count: int, max_per_member: int | None) -> None: + if max_per_member is not None and current_count >= max_per_member: + raise AppError( + "سقف بازخرید این پاداش برای این عضو تکمیل شده است", + status_code=409, + error_code="reward_max_per_member_exceeded", + ) + + +def ensure_member_active(status: MemberStatus) -> None: + if status != MemberStatus.ACTIVE: + raise AppError( + "عضو فعال نیست", + status_code=409, + error_code="member_not_active", + ) + + +def ensure_fulfillable(status: RedemptionStatus) -> None: + if status != RedemptionStatus.PENDING: + raise AppError( + "فقط درخواست‌های در انتظار قابل تحویل هستند", + status_code=409, + error_code="redemption_not_pending", + ) + + +def ensure_cancellable(status: RedemptionStatus) -> None: + if status != RedemptionStatus.PENDING: + raise AppError( + "فقط درخواست‌های در انتظار قابل لغو هستند", + status_code=409, + error_code="redemption_not_pending", + ) diff --git a/backend/services/loyalty/app/validators/wallet.py b/backend/services/loyalty/app/validators/wallet.py new file mode 100644 index 0000000..6ae96b9 --- /dev/null +++ b/backend/services/loyalty/app/validators/wallet.py @@ -0,0 +1,95 @@ +"""Wallet Engine validators — Phase 7.6.""" +from __future__ import annotations + +from shared.exceptions import AppError + +from app.models.types import WalletAccountStatus, WalletLedgerEntryType + + +def validate_wallet_account_status( + status: WalletAccountStatus | str, +) -> WalletAccountStatus: + if isinstance(status, WalletAccountStatus): + return status + try: + return WalletAccountStatus(status) + except ValueError as exc: + raise AppError( + "وضعیت کیف پول نامعتبر است", + status_code=422, + error_code="invalid_wallet_account_status", + ) from exc + + +def ensure_wallet_open(status: WalletAccountStatus) -> None: + if status == WalletAccountStatus.CLOSED: + raise AppError( + "کیف پول بسته است", + status_code=409, + error_code="wallet_account_closed", + ) + if status == WalletAccountStatus.FROZEN: + raise AppError( + "کیف پول مسدود است", + status_code=409, + error_code="wallet_account_frozen", + ) + + +def validate_positive_amount(amount: int, *, field: str = "amount") -> int: + if amount is None or amount <= 0: + raise AppError( + f"{field} باید بزرگ‌تر از صفر باشد", + status_code=422, + error_code="invalid_wallet_amount", + details={"field": field}, + ) + return amount + + +def validate_adjust_amount(amount: int) -> int: + if amount == 0: + raise AppError( + "مقدار تعدیل نمی‌تواند صفر باشد", + status_code=422, + error_code="invalid_wallet_adjust_amount", + ) + return amount + + +def signed_amount(entry_type: WalletLedgerEntryType, amount: int) -> int: + if entry_type in {WalletLedgerEntryType.CREDIT, WalletLedgerEntryType.TRANSFER_IN}: + return abs(amount) + if entry_type in {WalletLedgerEntryType.DEBIT, WalletLedgerEntryType.TRANSFER_OUT}: + return -abs(amount) + # ADJUST: caller supplies signed amount + return amount + + +def ensure_sufficient_funds(balance: int, new_balance: int, *, requested: int) -> None: + if new_balance < 0: + raise AppError( + "موجودی کیف پول کافی نیست", + status_code=409, + error_code="wallet_insufficient_funds", + details={"balance": balance, "requested": requested}, + ) + + +def ensure_same_currency(source_currency: str, target_currency: str) -> None: + if source_currency != target_currency: + raise AppError( + "انتقال بین ارزهای متفاوت مجاز نیست", + status_code=422, + error_code="wallet_currency_mismatch", + details={"source": source_currency, "target": target_currency}, + ) + + +def ensure_not_same_account(source_id, target_id) -> None: + if source_id == target_id: + raise AppError( + "انتقال به همان کیف پول مجاز نیست", + status_code=422, + error_code="wallet_transfer_same_account", + ) diff --git a/docker-compose.yml b/docker-compose.yml index 997f6d1..27c6b3a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -312,6 +312,181 @@ services: networks: - superapp_net + delivery-service: + build: + context: . + dockerfile: backend/services/delivery/Dockerfile.dev + container_name: superapp_delivery_service + restart: unless-stopped + env_file: + - .env + environment: + DELIVERY_DATABASE_URL: ${DELIVERY_DATABASE_URL} + DELIVERY_DATABASE_URL_SYNC: ${DELIVERY_DATABASE_URL_SYNC} + CORE_SERVICE_URL: ${CORE_SERVICE_URL:-http://core-service:8000} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000} + AUTH_REQUIRED: ${AUTH_REQUIRED:-true} + KEYCLOAK_SERVER_URL: ${KEYCLOAK_SERVER_URL:-http://keycloak:8080} + KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL:-http://localhost:8080} + KEYCLOAK_REALM: ${KEYCLOAK_REALM:-superapp} + JWT_VERIFY_SIGNATURE: ${JWT_VERIFY_SIGNATURE:-true} + ports: + - "8007:8007" + volumes: + - ./backend/services/delivery:/app + - ./backend/shared-lib:/shared-lib + depends_on: + postgres: + condition: service_healthy + core-service: + condition: service_started + command: > + sh -c "python scripts/ensure_db.py && + alembic upgrade head && + uvicorn app.main:app --host 0.0.0.0 --port 8007 --reload" + networks: + - superapp_net + + healthcare-service: + build: + context: . + dockerfile: backend/services/healthcare/Dockerfile.dev + container_name: superapp_healthcare_service + restart: unless-stopped + env_file: + - .env + environment: + HEALTHCARE_DATABASE_URL: ${HEALTHCARE_DATABASE_URL} + HEALTHCARE_DATABASE_URL_SYNC: ${HEALTHCARE_DATABASE_URL_SYNC} + CORE_SERVICE_URL: ${CORE_SERVICE_URL:-http://core-service:8000} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000} + AUTH_REQUIRED: ${AUTH_REQUIRED:-true} + KEYCLOAK_SERVER_URL: ${KEYCLOAK_SERVER_URL:-http://keycloak:8080} + KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL:-http://localhost:8080} + KEYCLOAK_REALM: ${KEYCLOAK_REALM:-superapp} + JWT_VERIFY_SIGNATURE: ${JWT_VERIFY_SIGNATURE:-true} + ports: + - "8010:8010" + volumes: + - ./backend/services/healthcare:/app + - ./backend/shared-lib:/shared-lib + depends_on: + postgres: + condition: service_healthy + core-service: + condition: service_started + command: > + sh -c "python scripts/ensure_db.py && + alembic upgrade head && + uvicorn app.main:app --host 0.0.0.0 --port 8010 --reload" + networks: + - superapp_net + + beauty-business-service: + build: + context: . + dockerfile: backend/services/beauty_business/Dockerfile.dev + container_name: superapp_beauty_business_service + restart: unless-stopped + env_file: + - .env + environment: + BEAUTY_BUSINESS_DATABASE_URL: ${BEAUTY_BUSINESS_DATABASE_URL} + BEAUTY_BUSINESS_DATABASE_URL_SYNC: ${BEAUTY_BUSINESS_DATABASE_URL_SYNC} + CORE_SERVICE_URL: ${CORE_SERVICE_URL:-http://core-service:8000} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000} + AUTH_REQUIRED: ${AUTH_REQUIRED:-true} + KEYCLOAK_SERVER_URL: ${KEYCLOAK_SERVER_URL:-http://keycloak:8080} + KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL:-http://localhost:8080} + KEYCLOAK_REALM: ${KEYCLOAK_REALM:-superapp} + JWT_VERIFY_SIGNATURE: ${JWT_VERIFY_SIGNATURE:-true} + ports: + - "8011:8011" + volumes: + - ./backend/services/beauty_business:/app + - ./backend/shared-lib:/shared-lib + depends_on: + postgres: + condition: service_healthy + core-service: + condition: service_started + command: > + sh -c "python scripts/ensure_db.py && + alembic upgrade head && + uvicorn app.main:app --host 0.0.0.0 --port 8011 --reload" + networks: + - superapp_net + + hospitality-service: + build: + context: . + dockerfile: backend/services/hospitality/Dockerfile.dev + container_name: superapp_hospitality_service + restart: unless-stopped + env_file: + - .env + environment: + HOSPITALITY_DATABASE_URL: ${HOSPITALITY_DATABASE_URL} + HOSPITALITY_DATABASE_URL_SYNC: ${HOSPITALITY_DATABASE_URL_SYNC} + CORE_SERVICE_URL: ${CORE_SERVICE_URL:-http://core-service:8000} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000} + AUTH_REQUIRED: ${AUTH_REQUIRED:-true} + KEYCLOAK_SERVER_URL: ${KEYCLOAK_SERVER_URL:-http://keycloak:8080} + KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL:-http://localhost:8080} + KEYCLOAK_REALM: ${KEYCLOAK_REALM:-superapp} + JWT_VERIFY_SIGNATURE: ${JWT_VERIFY_SIGNATURE:-true} + ports: + - "8009:8009" + volumes: + - ./backend/services/hospitality:/app + - ./backend/shared-lib:/shared-lib + depends_on: + postgres: + condition: service_healthy + core-service: + condition: service_started + command: > + sh -c "python scripts/ensure_db.py && + alembic upgrade head && + uvicorn app.main:app --host 0.0.0.0 --port 8009 --reload" + networks: + - superapp_net + + experience-service: + build: + context: . + dockerfile: backend/services/experience/Dockerfile.dev + container_name: superapp_experience_service + restart: unless-stopped + env_file: + - .env + environment: + EXPERIENCE_DATABASE_URL: ${EXPERIENCE_DATABASE_URL} + EXPERIENCE_DATABASE_URL_SYNC: ${EXPERIENCE_DATABASE_URL_SYNC} + CORE_SERVICE_URL: ${CORE_SERVICE_URL:-http://core-service:8000} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000} + AUTH_REQUIRED: ${AUTH_REQUIRED:-true} + KEYCLOAK_SERVER_URL: ${KEYCLOAK_SERVER_URL:-http://keycloak:8080} + KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL:-http://localhost:8080} + KEYCLOAK_REALM: ${KEYCLOAK_REALM:-superapp} + JWT_VERIFY_SIGNATURE: ${JWT_VERIFY_SIGNATURE:-true} + ports: + - "8008:8008" + volumes: + - ./backend/services/experience:/app + - ./backend/shared-lib:/shared-lib + depends_on: + postgres: + condition: service_healthy + core-service: + condition: service_started + command: > + sh -c "python scripts/ensure_db.py && + alembic upgrade head && + uvicorn app.main:app --host 0.0.0.0 --port 8008 --reload" + networks: + - superapp_net + frontend: build: context: ./frontend @@ -330,6 +505,12 @@ services: NEXT_PUBLIC_PLATFORM_BASE_DOMAIN: ${NEXT_PUBLIC_PLATFORM_BASE_DOMAIN:-torbatyar.ir} NEXT_PUBLIC_ACCOUNTING_API_URL: ${NEXT_PUBLIC_ACCOUNTING_API_URL:-http://localhost:8002} ACCOUNTING_SERVICE_URL: ${ACCOUNTING_SERVICE_URL:-http://accounting-service:8002} + NEXT_PUBLIC_HEALTHCARE_API_URL: ${NEXT_PUBLIC_HEALTHCARE_API_URL:-http://localhost:8010} + HEALTHCARE_SERVICE_URL: ${HEALTHCARE_SERVICE_URL:-http://healthcare-service:8010} + NEXT_PUBLIC_BEAUTY_BUSINESS_API_URL: ${NEXT_PUBLIC_BEAUTY_BUSINESS_API_URL:-http://localhost:8011} + BEAUTY_BUSINESS_SERVICE_URL: ${BEAUTY_BUSINESS_SERVICE_URL:-http://beauty-business-service:8011} + NEXT_PUBLIC_HOSPITALITY_API_URL: ${NEXT_PUBLIC_HOSPITALITY_API_URL:-http://localhost:8009} + HOSPITALITY_SERVICE_URL: ${HOSPITALITY_SERVICE_URL:-http://hospitality-service:8009} # polling برای hot-reload پایدار روی volume mount ویندوز WATCHPACK_POLLING: "true" CHOKIDAR_USEPOLLING: "true" diff --git a/docs/ai-framework/phase-manifest.yaml b/docs/ai-framework/phase-manifest.yaml index 17f8932..9f2034d 100644 --- a/docs/ai-framework/phase-manifest.yaml +++ b/docs/ai-framework/phase-manifest.yaml @@ -5,9 +5,14 @@ # Agents must keep this file aligned with docs/progress.md and docs/roadmap.md. schema_version: 1 -updated: "2026-07-25" -# Delivery Platform registration complete; next delivery-10.0 Foundation. -# Loyalty Phase 7.1 complete; 7.2 Point Engine next for Loyalty track. +updated: "2026-07-26" +# AF-Discovery complete: Enterprise Phase Discovery mandate (ADR-019). +# AF-Enterprise complete: Enterprise Development Framework upgrade (ADR-018). +# Hospitality Phase 12.3 complete; 12.4 POS Lite next for Hospitality track. +# Experience 11.2 Component Library complete; next experience-11.3 was completed — next experience-11.4 Template System. +# Experience 11.3 Theme & Layout complete; next experience-11.4 Template System. +# Delivery Platform registration complete; Phase 10.1 Driver Management complete; next delivery-10.2. +# Loyalty Phase 7.6 complete; 7.7 Gift Card Platform next for Loyalty track. phases: - id: core-1 @@ -85,6 +90,64 @@ phases: - docs/architecture/adr/ADR-013.md required_services: [] + - id: ai-framework-enterprise + name: Enterprise Development Framework Upgrade + area: Platform + description: Docs-only upgrade of the AI Development Framework into an Enterprise Development Framework — Definition of Done, mandatory phase artifacts, enterprise completeness, boundary rules, expanded gates/loop/templates. No business feature code. Extends ADR-013 via ADR-018. Does not modify completed business phases or implementations. + status: complete + dependencies: + - ai-framework + required_previous_phase: ai-framework + required_documents: + - docs/ai-framework/README.md + - docs/ai-framework/definition-of-done.md + - docs/ai-framework/mandatory-phase-artifacts.md + - docs/ai-framework/enterprise-completeness.md + - docs/ai-framework/boundary-rules.md + - docs/ai-framework/master-prompt.md + - docs/ai-framework/development-loop.md + - docs/ai-framework/quality-gates.md + - docs/ai-framework/phase-template.md + - docs/ai-framework/phase-handover.md + - docs/architecture/adr/ADR-018.md + - docs/phase-handover/phase-af-enterprise.md + required_services: [] + completion_criteria: + - DoD, mandatory artifacts, enterprise completeness, and boundary rules documents published + - Master prompt, development loop, quality gates, templates, prompt/cursor rules updated + - ADR-018 accepted; ADR index and glossary updated + - Framework handover complete; no business code changes + - Backward-compatible path docs/ai-framework/ retained + - Quality gates for documentation/architecture/manifest validation passed + + - id: ai-framework-discovery + name: Enterprise Phase Discovery Mandate + area: Platform + description: Docs-only framework enhancement — mandatory Enterprise Phase Discovery before every phase implementation. Agents must inventory roadmap, architecture, boundaries, prior handover, codebase, APIs, domain, events, permissions, aggregates, tests, and docs; derive missing current-phase production capabilities; never assume CRUD; never pull future phases or violate boundaries. ADR-019 extends ADR-018. No business feature code. + status: complete + dependencies: + - ai-framework-enterprise + required_previous_phase: ai-framework-enterprise + required_documents: + - docs/ai-framework/enterprise-phase-discovery.md + - docs/ai-framework/README.md + - docs/ai-framework/master-prompt.md + - docs/ai-framework/development-loop.md + - docs/ai-framework/quality-gates.md + - docs/ai-framework/phase-template.md + - docs/ai-framework/phase-handover.md + - docs/ai-framework/definition-of-done.md + - docs/architecture/adr/ADR-019.md + - docs/phase-handover/phase-af-discovery.md + required_services: [] + completion_criteria: + - enterprise-phase-discovery.md published with inputs, procedure, checklist, Discovery Record + - Loop stage 1 is Discovery; quality gates include Discovery gate + - Phase template/handover/prompt/cursor/DoD/completeness updated + - ADR-019 accepted; glossary/progress/manifests updated + - Framework handover complete; no business code changes + - Quality gates for documentation/architecture/manifest validation passed + # --- Accounting --- - id: accounting-5.1 name: Accounting Foundation @@ -300,27 +363,142 @@ phases: - id: loyalty-7.2 name: Point Engine area: Loyalty - status: planned + status: complete dependencies: - loyalty-7.1 + - ai-framework + required_documents: + - docs/loyalty-phase-7-2.md + - docs/phase-handover/phase-7-2.md + - docs/phases/Loyalty/README.md + - docs/architecture/adr/ADR-011.md + - docs/service-snapshots/loyalty.yaml + required_services: + - loyalty + + - id: loyalty-7.3 + name: Rewards Engine + area: Loyalty + status: complete + dependencies: + - loyalty-7.2 + - ai-framework + required_documents: + - docs/loyalty-phase-7-3.md + - docs/phase-handover/phase-7-3.md + - docs/phases/Loyalty/README.md + - docs/architecture/adr/ADR-011.md + - docs/service-snapshots/loyalty.yaml + required_services: + - loyalty + + - id: loyalty-7.4 + name: Campaign Engine + area: Loyalty + status: complete + dependencies: + - loyalty-7.3 + - ai-framework + required_documents: + - docs/loyalty-phase-7-4.md + - docs/phase-handover/phase-7-4.md + - docs/service-snapshots/loyalty.yaml + required_services: + - loyalty + + - id: loyalty-7.5 + name: Referral Engine + area: Loyalty + status: complete + dependencies: + - loyalty-7.4 + - ai-framework + required_documents: + - docs/loyalty-phase-7-5.md + - docs/phase-handover/phase-7-5.md + - docs/service-snapshots/loyalty.yaml + required_services: + - loyalty + + - id: loyalty-7.6 + name: Wallet + area: Loyalty + status: complete + dependencies: + - loyalty-7.5 + - ai-framework + required_documents: + - docs/loyalty-phase-7-6.md + - docs/phase-handover/phase-7-6.md + - docs/phases/Loyalty/README.md + - docs/architecture/adr/ADR-011.md + - docs/service-snapshots/loyalty.yaml + required_services: + - loyalty + + - id: loyalty-7.7 + name: Gift Card Platform + area: Loyalty + status: planned + dependencies: + - loyalty-7.6 required_documents: - docs/phases/Loyalty/README.md - docs/ai-framework/phase-template.md required_services: - loyalty + - id: loyalty-7.8 + name: Analytics + area: Loyalty + status: planned + dependencies: + - loyalty-7.7 + required_documents: + - docs/phases/Loyalty/README.md + required_services: + - loyalty + + - id: loyalty-7.9 + name: Public / Partner / Webhook APIs + area: Loyalty + status: planned + dependencies: + - loyalty-7.8 + required_documents: + - docs/phases/Loyalty/README.md + required_services: + - loyalty + + - id: loyalty-7.10 + name: Final Review + area: Loyalty + status: planned + dependencies: + - loyalty-7.9 + required_documents: + - docs/phases/Loyalty/README.md + required_services: + - loyalty + # --- SMS / Communication --- - id: communication-8 name: Enterprise Communication Platform (SMS-first) area: SMS + description: Independent communication-service (SMS MVP). Production Ready — feature-complete for current MVP scope; future channels in communication-roadmap.md only. status: complete dependencies: - onboarding-4 required_documents: - docs/communication-phase-8.md + - docs/communication-phase-8-audit.md + - docs/communication-roadmap.md + - docs/phase-handover/phase-8.md + - docs/service-snapshots/communication.yaml - docs/architecture/adr/ADR-012.md required_services: - communication + notes: MVP feature-complete (SMS). No registered 9.x phases. Email/Push/Telegram/WhatsApp/Rubika/Voice remain future roadmap items. - id: sms-panel-future name: Legacy SMS Panel Scaffold Alignment @@ -410,7 +588,7 @@ phases: name: Coach & Staff Management area: Sports Center description: Coaches, trainers, nutritionists, medical staff, reception, managers, working hours, certificates, skills, payroll refs, availability, permissions. - status: planned + status: complete dependencies: - sports-center-9.2 required_previous_phase: sports-center-9.2 @@ -428,7 +606,7 @@ phases: name: Scheduling & Booking area: Sports Center description: Sports classes, sessions, timetables, reservations, capacity, waiting lists, recurring sessions, resource allocation, facilities, equipment reservation, calendar. - status: planned + status: complete dependencies: - sports-center-9.3 required_previous_phase: sports-center-9.3 @@ -445,7 +623,7 @@ phases: name: Attendance & Access Control area: Sports Center description: Attendance, QR/RFID/barcode/face-ready check-in, manual attendance, entry/exit gates, late arrival, absence, attendance history. - status: planned + status: complete dependencies: - sports-center-9.4 required_previous_phase: sports-center-9.4 @@ -462,7 +640,7 @@ phases: name: Training Management area: Sports Center description: Training programs, exercises, workout plans, goals, measurements, progress tracking, nutrition plans, body composition, assessments, performance records. - status: planned + status: complete dependencies: - sports-center-9.5 required_previous_phase: sports-center-9.5 @@ -479,7 +657,7 @@ phases: name: Competition & Event Management area: Sports Center description: Competitions, tournaments, matches, teams, groups, registrations, rankings, results, medals, certificates, judges, schedules. - status: planned + status: complete dependencies: - sports-center-9.6 required_previous_phase: sports-center-9.6 @@ -594,7 +772,7 @@ phases: name: Delivery Platform Foundation area: Delivery description: Independent delivery service scaffold, delivery_db, health/capabilities/metrics, permissions delivery.*, publish-only event shells, tenant isolation, audit/config shells, provider/routing-engine contracts only. - status: planned + status: complete dependencies: - delivery-reg required_previous_phase: delivery-reg @@ -603,6 +781,8 @@ phases: - docs/phases/Delivery/README.md - docs/architecture/adr/ADR-015.md - docs/phase-handover/phase-dp-reg.md + - docs/delivery-phase-10-0.md + - docs/phase-handover/phase-10-0.md - docs/ai-framework/service-template.md - docs/ai-framework/phase-template.md required_services: @@ -619,7 +799,7 @@ phases: name: Driver Management area: Delivery description: Drivers, profiles, credential refs, status lifecycle, permissions, events, APIs. - status: planned + status: complete dependencies: - delivery-10.0 required_previous_phase: delivery-10.0 @@ -628,11 +808,15 @@ phases: - docs/phases/Delivery/README.md - docs/architecture/adr/ADR-015.md - docs/ai-framework/module-template.md + - docs/delivery-phase-10-1.md + - docs/phase-handover/phase-10-1.md required_services: - delivery completion_criteria: - Driver management APIs + validators + tests green + - Lifecycle, credentials/documents, outbox, list filter/sort/search - No fleet/dispatch/routing engines yet + - Quality gates passed; handover complete - id: delivery-10.2 name: Fleet & Vehicle Types @@ -800,19 +984,477 @@ phases: - Module registry version/phase updated; progress marks 10.10 complete - No undocumented public API/event/permission - # --- Restaurant --- - - id: restaurant-foundation - name: Restaurant / Cafe Foundation - area: Restaurant + # --- Experience Platform / Torbat Pages (Phase 11.0–11.10) --- + - id: experience-reg + name: Experience Platform Registration + area: Experience + description: Documentation-only registration of independent experience service, experience_db, phases 11.0–11.10, ADR-016, module/glossary/boundary updates. No business code. Prefer experience over historical website_builder scaffold. + status: complete + dependencies: + - onboarding-4 + - ai-framework + required_previous_phase: ai-framework + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + - docs/architecture/adr/ADR-016.md + - docs/experience-phase-xp-reg.md + - docs/phase-handover/phase-xp-reg.md + - docs/ai-framework/service-template.md + - docs/ai-framework/phase-template.md + required_services: + - experience + completion_criteria: + - Service registered in service-manifest with experience_db and experience.* permissions + - Phases 11.0–11.10 registered in phase-manifest + - Module registry, glossary, module-boundaries, ADR-016 updated + - Progress and next-steps updated; handover complete + - No business code, models, APIs, or migrations in this phase + - Quality gates for documentation/architecture/manifest validation passed + + - id: experience-11.0 + name: Experience Platform Foundation + area: Experience + description: Independent experience service scaffold, experience_db, health/capabilities/metrics, permissions experience.*, publish-only event shells, tenant isolation, audit/config shells, provider contracts only. + status: complete + dependencies: + - experience-reg + required_previous_phase: experience-reg + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + - docs/architecture/adr/ADR-016.md + - docs/phase-handover/phase-xp-reg.md + - docs/experience-phase-11-0.md + - docs/phase-handover/phase-11-0.md + - docs/ai-framework/service-template.md + - docs/ai-framework/phase-template.md + required_services: + - experience + completion_criteria: + - Service scaffold under backend/services/experience with API → Service → Repository → Model layering + - Alembic initial migration for foundation tables only as scoped + - Health, capabilities, metrics endpoints; permission tree experience.* documented + - Tenant isolation + architecture + docs tests green + - No page/theme/template engines beyond foundation shells + - Quality gates passed; handover complete + + - id: experience-11.1 + name: Sites & Page Resources + area: Experience + description: Sites, pages as resources, page types (landing, mini site, menu, bio, profile, event, campaign, portfolio, blog, QR, SEO, media, knowledge, …), lifecycle, permissions, events, APIs. + status: complete + dependencies: + - experience-11.0 + required_previous_phase: experience-11.0 + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + - docs/architecture/adr/ADR-016.md + - docs/experience-phase-11-1.md + - docs/phase-handover/phase-11-1.md + - docs/ai-framework/module-template.md + required_services: + - experience + completion_criteria: + - Site and page resource APIs + validators + tests green + - No component versioning / theme / template engines yet + + - id: experience-11.2 + name: Component Library & Versioning + area: Experience + description: Versioned components, component versions, composition shells, permissions, events, APIs. + status: complete + dependencies: + - experience-11.1 + required_previous_phase: experience-11.1 + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + - docs/experience-phase-11-2.md + - docs/phase-handover/phase-11-2.md + required_services: + - experience + completion_criteria: + - Component library with immutable versions; tenant isolation tests green + - No theme/layout engine yet + + - id: experience-11.3 + name: Theme & Layout Engine + area: Experience + description: Replaceable themes, dynamic layouts, directionality-ready shells. + status: complete + dependencies: + - experience-11.2 + required_previous_phase: experience-11.2 + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + - docs/architecture/adr/ADR-008.md + - docs/experience-phase-11-3.md + - docs/phase-handover/phase-11-3.md + required_services: + - experience + completion_criteria: + - Theme and layout APIs + tests green + - No template catalog engine yet + + - id: experience-11.4 + name: Template System + area: Experience + description: Template catalog, instantiation from templates, page-type starter packs. status: planned + dependencies: + - experience-11.3 + required_previous_phase: experience-11.3 + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + required_services: + - experience + completion_criteria: + - Template APIs + validators + tests green + - No forms/surveys engine yet + + - id: experience-11.5 + name: Localization, RTL/LTR & Media + area: Experience + description: Locales, RTL/LTR, media references via Storage only (no binaries in experience_db). + status: planned + dependencies: + - experience-11.4 + required_previous_phase: experience-11.4 + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + required_services: + - experience + completion_criteria: + - Localization and media-ref APIs + isolation tests green + - No form/survey/appointment engines yet + + - id: experience-11.6 + name: Forms, Surveys & Appointments + area: Experience + description: Forms, surveys, appointment page shells; submission/booking refs via APIs/events only. + status: planned + dependencies: + - experience-11.5 + required_previous_phase: experience-11.5 + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + required_services: + - experience + completion_criteria: + - Forms/surveys/appointment APIs + tests green + - No deep CRM/scheduling ownership; refs only + + - id: experience-11.7 + name: Publishing, Domains, SEO & PWA + area: Experience + description: Publish workflows, custom domain binding refs, SEO metadata/sitemap/robots shells, PWA readiness. + status: planned + dependencies: + - experience-11.6 + required_previous_phase: experience-11.6 + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + - docs/architecture/adr/ADR-009.md + required_services: + - experience + completion_criteria: + - Publishing/SEO/PWA APIs + tests green + - DNS/SSL edge remains Core/Nginx patterns; Experience stores binding refs + + - id: experience-11.8 + name: Bundles, Licensing & Feature Toggles + area: Experience + description: Capability bundles (Mini Site, Menu, Bio Link, …), licensing shells, feature toggles coordinated with Core entitlement. + status: planned + dependencies: + - experience-11.7 + required_previous_phase: experience-11.7 + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + required_services: + - experience + completion_criteria: + - Bundles/toggles APIs + validators + tests green + - No Core plan-engine ownership + + - id: experience-11.9 + name: Consumer Integrations & Widgets + area: Experience + description: Consumer connector contracts for Hospitality/Marketplace/Sports/CRM/etc., embeddable widgets, Communication notify client. + status: planned + dependencies: + - experience-11.8 + - communication-8 + required_previous_phase: experience-11.8 + required_documents: + - docs/experience-roadmap.md + - docs/architecture/adr/ADR-012.md + - docs/architecture/adr/ADR-016.md + required_services: + - experience + - communication + completion_criteria: + - Connector + widget contracts documented/tested + - No SMS provider ownership; UI remains in frontend + + - id: experience-11.10 + name: Analytics, AI Content & Enterprise Validation + area: Experience + description: Analytics shells, optional AI content hooks, full AI Framework validation — architecture, security, performance, docs, integration, tenant isolation, migration, API compatibility, self-heal until green. + status: planned + dependencies: + - experience-11.9 + - ai-framework + required_previous_phase: experience-11.9 + required_documents: + - docs/experience-roadmap.md + - docs/phases/Experience/README.md + - docs/ai-framework/quality-gates.md + - docs/ai-framework/phase-handover.md + - docs/architecture/ai-architecture.md + required_services: + - experience + completion_criteria: + - All Experience quality gates passed + - Core flows work when AI is off + - Module registry version/phase updated; progress marks 11.10 complete + - No undocumented public API/event/permission + + # --- Hospitality (Phase 12.0+) — Torbat Food --- + - id: hospitality-12.0 + name: Hospitality Platform Foundation + area: Hospitality + description: Independent hospitality service scaffold, hospitality_db, health/capabilities, bundle licensing + feature toggles, venue/menu/table shells, permissions hospitality.*, publish-only events, tenant isolation, audit shell. Commercial product Torbat Food. + status: complete + dependencies: + - onboarding-4 + - ai-framework + required_previous_phase: ai-framework + required_documents: + - docs/hospitality-roadmap.md + - docs/phases/Hospitality/README.md + - docs/architecture/adr/ADR-017.md + - docs/hospitality-phase-12-0.md + - docs/phase-handover/phase-12-0.md + - docs/ai-framework/service-template.md + - docs/ai-framework/phase-template.md + required_services: + - hospitality + completion_criteria: + - Service scaffold under backend/services/hospitality with API → Service → Repository → Model layering + - Alembic initial migration for foundation tables only as scoped + - Health + capabilities endpoints; permission tree hospitality.* documented + - Bundle definitions + tenant bundle activation + feature toggles + - Tenant isolation + architecture + docs tests green + - Module registry, manifests, progress, handover updated + - Quality gates passed; no POS/kitchen/ordering engines + + - id: hospitality-12.1 + name: Digital Menu Catalog + area: Hospitality + description: Deep digital menu catalog — modifiers, allergens, availability windows, media refs, multi-language shells. + status: complete + dependencies: + - hospitality-12.0 + required_previous_phase: hospitality-12.0 + required_documents: + - docs/hospitality-roadmap.md + - docs/phases/Hospitality/README.md + - docs/hospitality-phase-12-1.md + - docs/phase-handover/phase-12-1.md + - docs/ai-framework/module-template.md + required_services: + - hospitality + completion_criteria: + - Digital menu catalog APIs + validators + tests green + - No POS / kitchen / ordering engines yet + - Module registry, manifests, progress, handover updated + - Quality gates passed + + - id: hospitality-12.2 + name: QR Menu & QR Ordering + area: Hospitality + description: QR Menu public surfaces and QR Ordering shells consuming the digital menu catalog. + status: complete + dependencies: + - hospitality-12.1 + required_previous_phase: hospitality-12.1 + required_documents: + - docs/hospitality-roadmap.md + - docs/phases/Hospitality/README.md + - docs/hospitality-phase-12-2.md + - docs/phase-handover/phase-12-2.md + - docs/ai-framework/module-template.md + required_services: + - hospitality + completion_criteria: + - QR Menu / Ordering APIs + validators + tests green + - No POS / kitchen engines yet + + - id: hospitality-12.3 + name: Table Service & Reservations + area: Hospitality + description: Reservation, table assignment, service request, and waitlist aggregates for dine-in table service workflows. + status: complete + dependencies: + - hospitality-12.2 + required_previous_phase: hospitality-12.2 + required_documents: + - docs/hospitality-roadmap.md + - docs/phases/Hospitality/README.md + - docs/hospitality-phase-12-3.md + - docs/phase-handover/phase-12-3.md + - docs/ai-framework/module-template.md + required_services: + - hospitality + completion_criteria: + - Reservation / table assignment / service request / waitlist APIs + validators + tests green + - No POS / kitchen / payment engines + + - id: hospitality-12.4 + name: POS Lite + area: Hospitality + description: Lightweight point-of-sale shell for small venue formats. + status: complete + dependencies: + - hospitality-12.3 + required_previous_phase: hospitality-12.3 + required_documents: + - docs/hospitality-roadmap.md + - docs/phases/Hospitality/README.md + - docs/hospitality-phase-12-4.md + - docs/phase-handover/phase-12-4.md + required_services: + - hospitality + completion_criteria: + - POS Lite APIs + validators + tests green + + - id: hospitality-12.5 + name: POS Pro + area: Hospitality + description: Full-featured point-of-sale engine for larger venue formats. + status: complete + dependencies: + - hospitality-12.4 + required_previous_phase: hospitality-12.4 + required_documents: + - docs/hospitality-roadmap.md + - docs/phases/Hospitality/README.md + - docs/hospitality-phase-12-5.md + - docs/phase-handover/phase-12-5.md + required_services: + - hospitality + completion_criteria: + - POS Pro APIs + validators + tests green + + - id: hospitality-12.6 + name: Kitchen + area: Hospitality + description: Kitchen display / ticket routing engine consuming POS and ordering sessions. + status: complete + dependencies: + - hospitality-12.5 + required_previous_phase: hospitality-12.5 + required_documents: + - docs/hospitality-roadmap.md + - docs/phases/Hospitality/README.md + - docs/hospitality-phase-12-6.md + - docs/phase-handover/phase-12-6.md + required_services: + - hospitality + completion_criteria: + - Kitchen ticket APIs + validators + tests green + + - id: hospitality-12.7 + name: Delivery / Accounting / CRM / Loyalty / Communication / Website Connectors + area: Hospitality + description: Outbound connector contracts integrating Delivery, Accounting, CRM, Loyalty, Communication, and Website Builder platforms via API/Events only. + status: complete + dependencies: + - hospitality-12.6 + required_previous_phase: hospitality-12.6 + required_documents: + - docs/hospitality-roadmap.md + - docs/phases/Hospitality/README.md + - docs/hospitality-phase-12-7.md + - docs/phase-handover/phase-12-7.md + required_services: + - hospitality + completion_criteria: + - Connector contracts + tests green; no direct DB coupling + + - id: hospitality-12.8 + name: Analytics + area: Hospitality + description: Analytics shells and reporting surfaces for hospitality operations. + status: complete + dependencies: + - hospitality-12.7 + required_previous_phase: hospitality-12.7 + required_documents: + - docs/hospitality-roadmap.md + - docs/phases/Hospitality/README.md + - docs/hospitality-phase-12-8.md + - docs/phase-handover/phase-12-8.md + required_services: + - hospitality + completion_criteria: + - Analytics APIs + tests green + + - id: hospitality-12.9 + name: AI Assistant + area: Hospitality + description: AI assistant contracts for hospitality operations (optional, contract-only). + status: planned + dependencies: + - hospitality-12.8 + required_previous_phase: hospitality-12.8 + required_documents: + - docs/hospitality-roadmap.md + - docs/phases/Hospitality/README.md + required_services: + - hospitality + completion_criteria: + - AI assistant contracts + tests green + + - id: hospitality-12.10 + name: Enterprise Validation + area: Hospitality + description: Final enterprise-grade validation pass across the Hospitality Platform prior to GA. + status: planned + dependencies: + - hospitality-12.9 + required_previous_phase: hospitality-12.9 + required_documents: + - docs/hospitality-roadmap.md + - docs/phases/Hospitality/README.md + required_services: + - hospitality + completion_criteria: + - Full quality gate suite green across all Hospitality phases + + # Historical alias — superseded by hospitality-12.0 + - id: restaurant-foundation + name: Restaurant / Cafe Foundation (superseded) + area: Restaurant + status: complete + notes: Superseded by hospitality-12.0 (Hospitality Platform / Torbat Food). Kept for manifest continuity. dependencies: - onboarding-4 - ai-framework required_documents: + - docs/hospitality-phase-12-0.md - docs/phases/Restaurant/README.md - - docs/ai-framework/service-template.md required_services: - - restaurant + - hospitality # --- Marketplace / Ecommerce --- - id: ecommerce-foundation @@ -929,4 +1571,4 @@ phases: - docs/phases/Future/README.md - docs/roadmap.md required_services: [] - notes: Catch-all for website_builder, live_chat, smart_messenger, notification, file_storage, link_shortener, and unlisted future modules. + notes: Catch-all for live_chat, smart_messenger, notification, file_storage, link_shortener, historical website_builder scaffold, and unlisted future modules. Prefer experience (ADR-016) for page/site work. diff --git a/docs/ai-framework/project-index.yaml b/docs/ai-framework/project-index.yaml new file mode 100644 index 0000000..fbc8185 --- /dev/null +++ b/docs/ai-framework/project-index.yaml @@ -0,0 +1,624 @@ +# Project Index — single runtime discovery entry point for TorbatYar AI Framework. +# Normative rules: docs/ai-framework/master-prompt.md, runtime-read-policy.md +# NEVER scan repository folders to discover services, handovers, roadmaps, snapshots, ADRs, or architecture docs. +# Resolve all paths through this index. Update automatically on service registration and phase completion. + +schema_version: 1 +updated: "2026-07-26" + +execution_order: + - docs/ai-framework/project-index.yaml + - docs/ai-framework/runtime-read-policy.md + - docs/ai-framework/context-cache-policy.md + - service_snapshot_file + - docs/ai-framework/phase-manifest.yaml + - docs/ai-framework/service-manifest.yaml + - latest_handover + - additional_documents_per_runtime_read_policy + +shared_references: + phase_manifest: docs/ai-framework/phase-manifest.yaml + service_manifest: docs/ai-framework/service-manifest.yaml + module_registry: docs/module-registry.md + platform_roadmap: docs/roadmap.md + +services: + - service_name: Core Platform + commercial_product: TorbatYar Platform + service_identifier: core-platform + current_status: active + current_version: "0.4.x" + current_phase: onboarding-4 + next_phase: null + snapshot_file: docs/service-snapshots/core-platform.yaml + roadmap_file: docs/roadmap.md + latest_handover: null + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: core.* + database_name: core_platform_db + api_port: null + public_api_prefix: /api/v1 + event_prefix: tenant.* + capability_prefix: core.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: Identity & Access + commercial_product: Torbat Identity + service_identifier: identity-access + current_status: active + current_version: "0.2.x" + current_phase: identity-2 + next_phase: null + snapshot_file: docs/service-snapshots/identity-access.yaml + roadmap_file: docs/roadmap.md + latest_handover: null + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: identity.* + database_name: identity_access_db + api_port: 8001 + public_api_prefix: /api/v1 + event_prefix: user.registered + capability_prefix: identity.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: Accounting + commercial_product: Torbat Accounting + service_identifier: accounting + current_status: active + current_version: "0.5.11.0" + current_phase: accounting-5.11 + next_phase: null + snapshot_file: docs/service-snapshots/accounting.yaml + roadmap_file: docs/roadmap.md + latest_handover: null + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: accounting.* + database_name: accounting_db + api_port: 8002 + public_api_prefix: /api/v1 + event_prefix: voucher.posted + capability_prefix: accounting.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: CRM + commercial_product: Torbat CRM + service_identifier: crm + current_status: active + current_version: "0.6.3.0" + current_phase: crm-6.3 + next_phase: null + snapshot_file: docs/service-snapshots/crm.yaml + roadmap_file: docs/roadmap.md + latest_handover: null + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: crm.* + database_name: crm_db + api_port: 8003 + public_api_prefix: /api/v1 + event_prefix: crm.lead.* + capability_prefix: crm.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: Enterprise Loyalty Platform + commercial_product: Torbat Loyalty + service_identifier: loyalty + current_status: active + current_version: "0.7.6.0" + current_phase: loyalty-7.6 + next_phase: loyalty-7.7 + snapshot_file: docs/service-snapshots/loyalty.yaml + roadmap_file: docs/roadmap.md + latest_handover: docs/phase-handover/phase-7-6.md + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: loyalty.* + database_name: loyalty_db + api_port: 8004 + public_api_prefix: /api/v1 + event_prefix: loyalty.program.* + capability_prefix: loyalty.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: Enterprise Communication Platform + commercial_product: Torbat Communication + service_identifier: communication + current_status: active + current_version: "0.8.10.1" + current_phase: communication-8 + next_phase: null + snapshot_file: docs/service-snapshots/communication.yaml + roadmap_file: docs/communication-roadmap.md + latest_handover: docs/phase-handover/phase-8.md + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: communication.* + database_name: communication_db + api_port: 8005 + public_api_prefix: /api/v1 + event_prefix: communication.message.* + capability_prefix: communication.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: Sports Center Platform + commercial_product: Sports Center + service_identifier: sports_center + current_status: active + current_version: "0.9.7.0" + current_phase: sports-center-9.7 + next_phase: sports-center-9.8 + snapshot_file: docs/service-snapshots/sports-center.yaml + roadmap_file: docs/sports-center-roadmap.md + latest_handover: docs/phase-handover/phase-9-7.md + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: sports_center.* + database_name: sports_center_db + api_port: null + public_api_prefix: /api/v1 + event_prefix: sports_center.member.* + capability_prefix: sports_center.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: Delivery & Fleet Platform + commercial_product: Torbat Driver + service_identifier: delivery + current_status: active + current_version: "0.10.1.0" + current_phase: delivery-10.1 + next_phase: delivery-10.2 + snapshot_file: docs/service-snapshots/delivery.yaml + roadmap_file: docs/delivery-roadmap.md + latest_handover: docs/phase-handover/phase-10-1.md + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: delivery.* + database_name: delivery_db + api_port: 8007 + public_api_prefix: /api/v1 + event_prefix: delivery.driver.* + capability_prefix: delivery.* + health_endpoint: /health + metrics_endpoint: /metrics + + - service_name: Enterprise Experience Platform + commercial_product: Torbat Pages + service_identifier: experience + current_status: active + current_version: "0.11.3.0" + current_phase: experience-11.3 + next_phase: experience-11.4 + snapshot_file: docs/service-snapshots/experience.yaml + roadmap_file: docs/experience-roadmap.md + latest_handover: docs/phase-handover/phase-11-3.md + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: experience.* + database_name: experience_db + api_port: 8008 + public_api_prefix: /api/v1 + event_prefix: experience.site.* + capability_prefix: experience.* + health_endpoint: /health + metrics_endpoint: /metrics + + - service_name: Hospitality Platform + commercial_product: Torbat Food + service_identifier: hospitality + current_status: active + current_version: "0.12.8.0" + current_phase: hospitality-12.8 + next_phase: hospitality-12.9 + snapshot_file: docs/service-snapshots/hospitality.yaml + roadmap_file: docs/hospitality-roadmap.md + latest_handover: docs/phase-handover/phase-12-8.md + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: hospitality.* + database_name: hospitality_db + api_port: 8009 + public_api_prefix: /api/v1 + event_prefix: hospitality.venue.* + capability_prefix: hospitality.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: Restaurant / Cafe (historical scaffold) + commercial_product: Torbat Food + service_identifier: restaurant + current_status: superseded + current_version: "0.12.1.0" + current_phase: hospitality-12.1 + next_phase: null + snapshot_file: null + roadmap_file: docs/hospitality-roadmap.md + latest_handover: docs/phase-handover/phase-12-1.md + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: hospitality.* + database_name: hospitality_db + api_port: null + public_api_prefix: /api/v1 + event_prefix: hospitality.* + capability_prefix: hospitality.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: Ecommerce + commercial_product: Torbat Commerce + service_identifier: ecommerce + current_status: scaffolded + current_version: "0.0.0" + current_phase: ecommerce-foundation + next_phase: null + snapshot_file: docs/service-snapshots/ecommerce.yaml + roadmap_file: docs/roadmap.md + latest_handover: null + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: ecommerce.* + database_name: ecommerce_db + api_port: null + public_api_prefix: /api/v1 + event_prefix: order.* + capability_prefix: ecommerce.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: Marketplace + commercial_product: Torbat Marketplace + service_identifier: marketplace + current_status: planned + current_version: "0.0.0" + current_phase: marketplace-foundation + next_phase: null + snapshot_file: docs/service-snapshots/marketplace.yaml + roadmap_file: docs/phases/Marketplace/README.md + latest_handover: null + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: marketplace.* + database_name: TBD + api_port: null + public_api_prefix: TBD + event_prefix: null + capability_prefix: marketplace.* + health_endpoint: TBD + metrics_endpoint: null + + - service_name: Automation + commercial_product: Torbat Automation + service_identifier: automation + current_status: planned + current_version: "0.0.0" + current_phase: automation-foundation + next_phase: null + snapshot_file: docs/service-snapshots/automation.yaml + roadmap_file: docs/phases/Automation/README.md + latest_handover: null + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: automation.* + database_name: TBD + api_port: null + public_api_prefix: TBD + event_prefix: null + capability_prefix: automation.* + health_endpoint: TBD + metrics_endpoint: null + + - service_name: Academy + commercial_product: Torbat Academy + service_identifier: academy + current_status: planned + current_version: "0.0.0" + current_phase: academy-foundation + next_phase: null + snapshot_file: docs/service-snapshots/academy.yaml + roadmap_file: docs/phases/Future/README.md + latest_handover: null + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: academy.* + database_name: academy_db + api_port: null + public_api_prefix: /api/v1 + event_prefix: null + capability_prefix: academy.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: Clinic + commercial_product: Torbat Clinic + service_identifier: clinic + current_status: planned + current_version: "0.0.0" + current_phase: clinic-foundation + next_phase: null + snapshot_file: docs/service-snapshots/clinic.yaml + roadmap_file: docs/phases/Future/README.md + latest_handover: null + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: clinic.* + database_name: clinic_db + api_port: null + public_api_prefix: /api/v1 + event_prefix: null + capability_prefix: clinic.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: Hotel + commercial_product: Torbat Hotel + service_identifier: hotel + current_status: planned + current_version: "0.0.0" + current_phase: hotel-foundation + next_phase: null + snapshot_file: docs/service-snapshots/hotel.yaml + roadmap_file: docs/phases/Future/README.md + latest_handover: null + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: hotel.* + database_name: hotel_db + api_port: null + public_api_prefix: /api/v1 + event_prefix: null + capability_prefix: hotel.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: Tourism + commercial_product: Torbat Tourism + service_identifier: tourism + current_status: planned + current_version: "0.0.0" + current_phase: tourism-foundation + next_phase: null + snapshot_file: docs/service-snapshots/tourism.yaml + roadmap_file: docs/phases/Future/README.md + latest_handover: null + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: tourism.* + database_name: tourism_db + api_port: null + public_api_prefix: /api/v1 + event_prefix: null + capability_prefix: tourism.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: AI Assistant + commercial_product: Torbat AI + service_identifier: ai_assistant + current_status: scaffolded + current_version: "0.0.0" + current_phase: ai-assistant-foundation + next_phase: null + snapshot_file: docs/service-snapshots/ai-assistant.yaml + roadmap_file: docs/phases/AI/README.md + latest_handover: null + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: ai_assistant.* + database_name: ai_assistant_db + api_port: null + public_api_prefix: /api/v1 + event_prefix: assistant.* + capability_prefix: ai_assistant.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: Website Builder + commercial_product: Torbat Pages + service_identifier: website_builder + current_status: scaffolded + current_version: "0.0.0" + current_phase: future-services + next_phase: null + snapshot_file: null + roadmap_file: docs/experience-roadmap.md + latest_handover: null + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: website_builder.* + database_name: website_builder_db + api_port: null + public_api_prefix: /api/v1 + event_prefix: null + capability_prefix: website_builder.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: Live Chat + commercial_product: Torbat Live Chat + service_identifier: live_chat + current_status: scaffolded + current_version: "0.0.0" + current_phase: future-services + next_phase: null + snapshot_file: docs/service-snapshots/live-chat.yaml + roadmap_file: docs/roadmap.md + latest_handover: null + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: live_chat.* + database_name: live_chat_db + api_port: null + public_api_prefix: /api/v1 + event_prefix: conversation.* + capability_prefix: live_chat.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: Smart Messenger + commercial_product: Torbat Messenger + service_identifier: smart_messenger + current_status: scaffolded + current_version: "0.0.0" + current_phase: future-services + next_phase: null + snapshot_file: docs/service-snapshots/smart-messenger.yaml + roadmap_file: docs/roadmap.md + latest_handover: null + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: smart_messenger.* + database_name: smart_messenger_db + api_port: null + public_api_prefix: /api/v1 + event_prefix: null + capability_prefix: smart_messenger.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: SMS Panel + commercial_product: Torbat SMS + service_identifier: sms_panel + current_status: scaffolded + current_version: "0.0.0" + current_phase: sms-panel-future + next_phase: null + snapshot_file: docs/service-snapshots/sms-panel.yaml + roadmap_file: docs/roadmap.md + latest_handover: null + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: sms_panel.* + database_name: sms_panel_db + api_port: null + public_api_prefix: /api/v1 + event_prefix: null + capability_prefix: sms_panel.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: Notification + commercial_product: Torbat Notification + service_identifier: notification + current_status: scaffolded + current_version: "0.0.0" + current_phase: future-services + next_phase: null + snapshot_file: docs/service-snapshots/notification.yaml + roadmap_file: docs/roadmap.md + latest_handover: null + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: notification.* + database_name: notification_db + api_port: null + public_api_prefix: /api/v1 + event_prefix: null + capability_prefix: notification.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: File Storage + commercial_product: Torbat Storage + service_identifier: file_storage + current_status: scaffolded + current_version: "0.0.0" + current_phase: future-services + next_phase: null + snapshot_file: docs/service-snapshots/file-storage.yaml + roadmap_file: docs/roadmap.md + latest_handover: null + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: file_storage.* + database_name: file_storage_db + api_port: null + public_api_prefix: /api/v1 + event_prefix: null + capability_prefix: file_storage.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: Link Shortener + commercial_product: Torbat Links + service_identifier: link_shortener + current_status: scaffolded + current_version: "0.0.0" + current_phase: future-services + next_phase: null + snapshot_file: docs/service-snapshots/link-shortener.yaml + roadmap_file: docs/roadmap.md + latest_handover: null + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: link_shortener.* + database_name: link_shortener_db + api_port: null + public_api_prefix: /api/v1 + event_prefix: null + capability_prefix: link_shortener.* + health_endpoint: /health + metrics_endpoint: null + + - service_name: Frontend + commercial_product: TorbatYar UI + service_identifier: frontend + current_status: active + current_version: "Next-15.5.x" + current_phase: onboarding-4 + next_phase: null + snapshot_file: null + roadmap_file: docs/frontend/README.md + latest_handover: null + phase_manifest_reference: docs/ai-framework/phase-manifest.yaml + service_manifest_reference: docs/ai-framework/service-manifest.yaml + module_registry_reference: docs/module-registry.md + permission_prefix: UI gated by /me roles + database_name: null + api_port: null + public_api_prefix: consumes /api/v1 + event_prefix: null + capability_prefix: null + health_endpoint: null + metrics_endpoint: null + +framework_workstreams: + - workstream_id: ai-framework + description: AI Development Framework (docs-only) + project_index_handover: docs/phase-handover/phase-af-project-index.md + snapshot_file: null diff --git a/docs/ai-framework/service-manifest.yaml b/docs/ai-framework/service-manifest.yaml index 85e42d0..372351f 100644 --- a/docs/ai-framework/service-manifest.yaml +++ b/docs/ai-framework/service-manifest.yaml @@ -125,22 +125,32 @@ services: - loyalty.tier.* - loyalty.member.* - loyalty.point_account.* + - loyalty.points.* - loyalty.reward.* - loyalty.campaign.* + - loyalty.referral.* + - loyalty.wallet.* health_endpoint: /health documentation: - docs/loyalty-phase-7-0.md - docs/loyalty-phase-7-1.md - - docs/phase-handover/phase-7-1.md + - docs/loyalty-phase-7-2.md + - docs/loyalty-phase-7-3.md + - docs/loyalty-phase-7-4.md + - docs/loyalty-phase-7-5.md + - docs/loyalty-phase-7-6.md + - docs/phase-handover/phase-7-6.md + - docs/service-snapshots/loyalty.yaml - docs/phases/Loyalty/README.md - docs/architecture/adr/ADR-011.md - current_version: 0.7.1.0 - current_phase: loyalty-7.1 + current_version: 0.7.6.0 + current_phase: loyalty-7.6 status: active api_port: 8004 - id: communication name: Enterprise Communication Platform + description: Independent shared messaging platform (Torbat Communication). Production Ready for SMS MVP; future channels documented in communication-roadmap.md only. owner: Platform path: backend/services/communication database: communication_db @@ -158,8 +168,12 @@ services: health_endpoint: /health documentation: - docs/communication-phase-8.md + - docs/communication-phase-8-audit.md + - docs/communication-roadmap.md + - docs/phase-handover/phase-8.md + - docs/service-snapshots/communication.yaml - docs/architecture/adr/ADR-012.md - current_version: 0.8.10.0 + current_version: 0.8.10.1 current_phase: communication-8 status: active api_port: 8005 @@ -241,12 +255,17 @@ services: - docs/sports-center-phase-9-0.md - docs/sports-center-phase-9-1.md - docs/sports-center-phase-9-2.md - - docs/phase-handover/phase-9-2.md + - docs/sports-center-phase-9-3.md + - docs/sports-center-phase-9-4.md + - docs/sports-center-phase-9-5.md + - docs/sports-center-phase-9-6.md + - docs/sports-center-phase-9-7.md + - docs/phase-handover/phase-9-7.md - docs/phases/SportsCenter/README.md - docs/architecture/adr/ADR-014.md - docs/module-registry.md - current_version: 0.9.2.0 - current_phase: sports-center-9.2 + current_version: 0.9.7.0 + current_phase: sports-center-9.7 current_status: active status: active future_modules: @@ -294,6 +313,12 @@ services: - loyalty - communication events: + - delivery.organization.* + - delivery.hub.* + - delivery.external_provider.* + - delivery.routing_engine.* + - delivery.configuration.* + - delivery.setting.* - delivery.driver.* - delivery.fleet.* - delivery.vehicle.* @@ -308,25 +333,36 @@ services: - /health - /capabilities - /metrics - - /api/v1 (phase-scoped resources) + - /api/v1/organizations + - /api/v1/hubs + - /api/v1/external-providers + - /api/v1/routing-engines + - /api/v1/configurations + - /api/v1/settings + - /api/v1/audit + - /api/v1/drivers + - /api/v1/permissions/catalog internal_apis: - service-to-service merchant connector and settlement refs (token-gated; not public) tenant_aware: true audit_enabled: true documentation: - docs/delivery-roadmap.md + - docs/delivery-phase-10-0.md + - docs/delivery-phase-10-1.md - docs/phases/Delivery/README.md - docs/phase-handover/phase-dp-reg.md + - docs/phase-handover/phase-10-0.md + - docs/phase-handover/phase-10-1.md - docs/architecture/adr/ADR-015.md - docs/module-registry.md - current_version: 0.0.0 - current_phase: delivery-reg - current_status: registered - status: planned + current_version: 0.10.1.0 + current_phase: delivery-10.1 + current_status: active + status: active api_port: 8007 commercial_product: Torbat Driver future_modules: - - drivers - fleet - vehicle_types - vehicles @@ -351,24 +387,300 @@ services: - ai_hooks - external_providers - - id: restaurant - name: Restaurant / Cafe - owner: TBD - path: backend/services/restaurant - database: restaurant_db + - id: experience + name: Enterprise Experience Platform + description: Independent multi-tenant experience platform (Torbat Pages) — sites, pages-as-resources, versioned components, themes, layouts, templates, localization, forms, SEO/PWA, bundles, widgets. Consumed by Hospitality, Marketplace, Sports Center, CRM, and future verticals via API/Events only. + owner: Platform + path: backend/services/experience + database: experience_db api_prefix: /api/v1 - permission_prefix: restaurant.* + permission_prefix: experience.* + health_endpoint: /health + configuration: + - EXPERIENCE_DATABASE_URL + - AUTH_REQUIRED + - tenant resolution via X-Tenant-ID / shared middleware + - entitlement feature keys experience.* dependencies: - core-platform + - shared-lib + optional_dependencies: + - file_storage + - crm - loyalty + - communication + - accounting + - sports_center + - delivery + - ai_assistant + - automation events: - - order.* + - experience.workspace.* + - experience.locale_profile.* + - experience.external_provider.* + - experience.render_engine.* + - experience.configuration.* + - experience.setting.* + - experience.site.* + - experience.page.* + - experience.site_domain.* + - experience.component.* + - experience.component_version.* + - experience.page_component.* + - experience.theme.* + - experience.layout.* + - experience.template.* + - experience.form.* + - experience.publish.* + - experience.domain.* + - experience.bundle.* + - experience.widget.* + public_apis: + - /health + - /capabilities + - /metrics + - /api/v1/workspaces + - /api/v1/locale-profiles + - /api/v1/external-providers + - /api/v1/render-engines + - /api/v1/configurations + - /api/v1/settings + - /api/v1/audit + - /api/v1/sites + - /api/v1/pages + - /api/v1/site-domains + - /api/v1/components + - /api/v1/component-versions + - /api/v1/page-component-placements + - /api/v1/themes + - /api/v1/layouts + - /api/v1/site-theme-assignments + - /api/v1/page-layout-assignments + internal_apis: + - service-to-service consumer connector refs (token-gated; not public) + tenant_aware: true + audit_enabled: true + documentation: + - docs/experience-roadmap.md + - docs/experience-phase-xp-reg.md + - docs/experience-phase-11-0.md + - docs/experience-phase-11-1.md + - docs/experience-phase-11-2.md + - docs/experience-phase-11-3.md + - docs/phases/Experience/README.md + - docs/phase-handover/phase-xp-reg.md + - docs/phase-handover/phase-11-0.md + - docs/phase-handover/phase-11-1.md + - docs/phase-handover/phase-11-2.md + - docs/phase-handover/phase-11-3.md + - docs/architecture/adr/ADR-016.md + - docs/module-registry.md + current_version: 0.11.3.0 + current_phase: experience-11.3 + current_status: active + status: active + api_port: 8008 + commercial_product: Torbat Pages + future_modules: + - sites + - pages + - page_types + - components + - component_versions + - themes + - layouts + - templates + - locales + - media_refs + - forms + - surveys + - appointments + - publishing + - domains + - seo + - pwa + - bundles + - feature_toggles + - widgets + - consumer_connectors + - analytics + - ai_hooks + - audit + - configuration + + - id: hospitality + name: Hospitality Platform + description: Independent multi-tenant F&B / hospitality operations platform (Torbat Food) — venues, digital menu catalog, tables, bundle licensing, feature toggles; future QR ordering/POS/kitchen/reservation/connectors. Integrates Accounting, CRM, Loyalty, Communication, Delivery, Website Builder, AI via API/Events only. + owner: Platform + path: backend/services/hospitality + database: hospitality_db + api_prefix: /api/v1 + permission_prefix: hospitality.* + health_endpoint: /health + configuration: + - HOSPITALITY_DATABASE_URL + - AUTH_REQUIRED + - tenant resolution via X-Tenant-ID / shared middleware + - entitlement / bundle feature keys hospitality.* + dependencies: + - core-platform + - shared-lib + optional_dependencies: + - accounting + - crm + - loyalty + - communication + - delivery + - website_builder + events: + - hospitality.venue.* + - hospitality.branch.* + - hospitality.menu.* + - hospitality.menu_category.* + - hospitality.menu_item.* + - hospitality.allergen.* + - hospitality.modifier_group.* + - hospitality.modifier_option.* + - hospitality.menu_item_modifier.* + - hospitality.menu_item_allergen.* + - hospitality.availability_window.* + - hospitality.menu_media_ref.* + - hospitality.menu_localization.* + - hospitality.dining_area.* + - hospitality.table.* + - hospitality.bundle_definition.* + - hospitality.tenant_bundle.* + - hospitality.feature_toggle.* + - hospitality.configuration.* + - hospitality.setting.* + - hospitality.qr_code.* + - hospitality.qr_menu_session.* + - hospitality.qr_ordering_session.* + - hospitality.cart.* + - hospitality.cart_line.* + - hospitality.reservation.* + - hospitality.table_assignment.* + - hospitality.service_request.* + - hospitality.waitlist_entry.* + - hospitality.pos_register.* + - hospitality.pos_shift.* + - hospitality.pos_ticket.* + - hospitality.pos_ticket_line.* + - hospitality.pos_payment.* + - hospitality.pos_discount.* + - hospitality.pos_tax_line.* + - hospitality.pos_floor_plan.* + - hospitality.pos_station.* + - hospitality.kitchen_station.* + - hospitality.kitchen_ticket.* + - hospitality.kitchen_ticket_item.* + - hospitality.connector_registration.* + - hospitality.connector_dispatch.* + - hospitality.analytics_report_definition.* + - hospitality.analytics_snapshot.* + public_apis: + - /health + - /capabilities + - /api/v1/venues + - /api/v1/branches + - /api/v1/dining-areas + - /api/v1/tables + - /api/v1/menus + - /api/v1/menu-categories + - /api/v1/menu-items + - /api/v1/allergens + - /api/v1/modifier-groups + - /api/v1/modifier-options + - /api/v1/menu-item-modifiers + - /api/v1/menu-item-allergens + - /api/v1/availability-windows + - /api/v1/menu-media-refs + - /api/v1/menu-localizations + - /api/v1/qr-codes + - /api/v1/qr-menu-sessions + - /api/v1/qr-ordering-sessions + - /api/v1/carts + - /api/v1/cart-lines + - /api/v1/reservations + - /api/v1/table-assignments + - /api/v1/service-requests + - /api/v1/waitlist + - /api/v1/bundle-definitions + - /api/v1/tenant-bundles + - /api/v1/feature-toggles + - /api/v1/roles + - /api/v1/permissions + - /api/v1/configurations + - /api/v1/events + - /api/v1/settings + internal_apis: + - service-to-service refs for Accounting / CRM / Loyalty / Communication / Delivery / Website (token-gated; not public) + tenant_aware: true + audit_enabled: true + documentation: + - docs/hospitality-roadmap.md + - docs/hospitality-phase-12-0.md + - docs/hospitality-phase-12-1.md + - docs/hospitality-phase-12-2.md + - docs/hospitality-phase-12-3.md + - docs/hospitality-phase-12-4.md + - docs/hospitality-phase-12-5.md + - docs/hospitality-phase-12-6.md + - docs/hospitality-phase-12-7.md + - docs/hospitality-phase-12-8.md + - docs/phase-handover/phase-12-0.md + - docs/phase-handover/phase-12-1.md + - docs/phase-handover/phase-12-2.md + - docs/phase-handover/phase-12-3.md + - docs/phase-handover/phase-12-4.md + - docs/phase-handover/phase-12-5.md + - docs/phase-handover/phase-12-6.md + - docs/phase-handover/phase-12-7.md + - docs/phase-handover/phase-12-8.md + - docs/phases/Hospitality/README.md + - docs/architecture/adr/ADR-017.md + - docs/module-registry.md + current_version: 0.12.8.0 + current_phase: hospitality-12.8 + current_status: active + status: active + api_port: 8009 + commercial_product: Torbat Food + future_modules: + - pos_lite + - pos_pro + - kitchen + - delivery_connector + - accounting_connector + - crm_connector + - loyalty_connector + - communication_connector + - website_connector + - analytics + - ai_assistant + + - id: restaurant + name: Restaurant / Cafe (historical scaffold) + owner: Platform + path: backend/services/restaurant + database: hospitality_db + api_prefix: /api/v1 + permission_prefix: hospitality.* + dependencies: + - hospitality + events: + - hospitality.* health_endpoint: /health documentation: - docs/phases/Restaurant/README.md - current_version: 0.0.0 - current_phase: restaurant-foundation - status: scaffolded + - docs/hospitality-phase-12-0.md + - docs/hospitality-phase-12-1.md + - docs/hospitality-phase-12-2.md + - docs/hospitality-phase-12-3.md + current_version: 0.12.3.0 + current_phase: hospitality-12.3 + status: superseded + notes: Pointer only — runtime is hospitality service (ADR-017). - id: ecommerce name: Ecommerce @@ -524,9 +836,11 @@ services: health_endpoint: /health documentation: - docs/module-registry.md + - docs/architecture/adr/ADR-016.md current_version: 0.0.0 current_phase: future-services status: scaffolded + notes: Prefer experience service (ADR-016 / Torbat Pages) for new page/site work. Historical scaffold only. - id: live_chat name: Live Chat diff --git a/docs/loyalty-phase-7-2.md b/docs/loyalty-phase-7-2.md new file mode 100644 index 0000000..e45e9b5 --- /dev/null +++ b/docs/loyalty-phase-7-2.md @@ -0,0 +1,101 @@ +# Phase 7.2 — Point Engine (Immutable Ledger) + +| Field | Value | +| --- | --- | +| Identifier | `loyalty-7.2` | +| Status | Complete | +| Module | loyalty | +| Service | `loyalty-service` | +| Version | `0.7.2.0` | +| Database | `loyalty_db` | +| Depends On | Phase 7.1 | +| ADR(s) | ADR-001, ADR-003, ADR-006, ADR-011 | +| Manifest | [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | + +## Objective + +Deliver the Point Engine: append-only immutable ledger as the sole source of point balances, with earn / redeem / adjust / expire operations, idempotency, and balance APIs — without rewards redemption, campaigns, wallet, or gift cards. + +## Scope + +### In Scope + +- `PointLedgerEntry` append-only model (signed amounts; `balance_after`) +- Balance = `SUM(amount)` — never stored on `PointAccount` +- Earn, redeem, adjust, expire operations +- Idempotency via optional `idempotency_key` (tenant-unique) +- Insufficient-balance rejection on redeem/expire +- Permissions, events, validators, migration `0003_phase_72_points` +- Tests + documentation + handover + service snapshot + +### Out of Scope + +- Reward catalog redemption flows (7.3) +- Campaign rule engine (7.4) +- Referral / wallet / gift cards / analytics / partner APIs (7.5–7.9) + +## Ledger Rules + +| Rule | Detail | +| --- | --- | +| Append-only | No UPDATE/DELETE of ledger rows | +| Signed amounts | Earn/adjust+ positive; redeem/expire/adjust− negative | +| Open accounts only | Frozen/closed accounts reject mutations | +| Idempotency | Same `(tenant_id, idempotency_key)` returns existing entry | +| Balance | Computed from ledger; never a mutable column on `PointAccount` | + +## Models + +| Entity | Soft delete | Audit | Tenant | +| --- | --- | --- | --- | +| PointLedgerEntry | No (append-only) | Via fields + audit log | Yes | + +## APIs + +| Method | Path | Permission | +| --- | --- | --- | +| GET | `/api/v1/point-accounts/{id}/balance` | `loyalty.points.view` | +| GET | `/api/v1/point-accounts/{id}/ledger` | `loyalty.points.view` | +| POST | `/api/v1/point-accounts/{id}/earn` | `loyalty.points.earn` | +| POST | `/api/v1/point-accounts/{id}/redeem` | `loyalty.points.redeem` | +| POST | `/api/v1/point-accounts/{id}/adjust` | `loyalty.points.adjust` | +| POST | `/api/v1/point-accounts/{id}/expire-points` | `loyalty.points.expire` | + +## Events + +| Event | When | +| --- | --- | +| `loyalty.points.earned` | Earn | +| `loyalty.points.redeemed` | Redeem | +| `loyalty.points.adjusted` | Adjust | +| `loyalty.points.expired` | Expire points | + +## Migration + +| Item | Detail | +| --- | --- | +| Alembic | `0003_phase_72_points` (down_revision `0002_phase_71_membership`) | +| Breaking | None — additive table | +| Backfill | None required | + +## Tests + +Command: `cd backend/services/loyalty && pytest -q` → **61 passed** + +## Known Limitations + +- No automatic expire scheduler (API-driven expire only) +- No FIFO lot tracking for expiration (bulk expire by amount) +- Transfer still does not migrate ledger entries (explicit follow-up if needed) + +## Next Phase + +Phase 7.3 — Rewards Engine + +## Related Documents + +- [Handover](phase-handover/phase-7-2.md) +- [Phase 7.1](loyalty-phase-7-1.md) +- [ADR-011](architecture/adr/ADR-011.md) +- [Service Snapshot](service-snapshots/loyalty.yaml) +- [Module Registry](module-registry.md#loyalty) diff --git a/docs/loyalty-phase-7-3.md b/docs/loyalty-phase-7-3.md new file mode 100644 index 0000000..81f84a1 --- /dev/null +++ b/docs/loyalty-phase-7-3.md @@ -0,0 +1,92 @@ +# Phase 7.3 — Rewards Engine + +| Field | Value | +| --- | --- | +| Identifier | `loyalty-7.3` | +| Status | Complete | +| Module | loyalty | +| Service | `loyalty-service` | +| Version | `0.7.3.0` | +| Database | `loyalty_db` | +| Depends On | Phase 7.2 | +| ADR(s) | ADR-001, ADR-003, ADR-006, ADR-011 | +| Manifest | [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | + +## Objective + +Deliver the Rewards Engine: redemption lifecycle on catalog rewards with points debited exclusively through the immutable Point Engine — without campaign rules, referral, wallet, or gift cards. + +## Scope + +### In Scope + +- Reward inventory fields: `stock_limit`, `redeemed_count`, `max_per_member` +- `RewardRedemption` lifecycle: pending → fulfilled | cancelled +- Redeem / fulfill / cancel APIs; list by reward and globally +- Points debit/refund via `PointEngineService` (`auto_commit=False` composition) +- Permissions, events, validators, migration `0004_phase_73_rewards` +- Tests + documentation + handover + service snapshot + +### Out of Scope + +- Campaign rule engine (7.4) +- Referral / wallet / gift cards / analytics / partner APIs (7.5–7.9) + +## Redemption Rules + +| Rule | Detail | +| --- | --- | +| Active reward only | Status must be `active` | +| Stock | `redeemed_count < stock_limit` when limit set | +| Per-member cap | Non-cancelled count ≤ `max_per_member` when set | +| Member | Must be `active` | +| Points | Debit via ledger; free (`points_cost=0`) skips ledger | +| Cancel | Only from `pending`; refunds points; decrements stock counter | +| Fulfill | Only from `pending` | + +## APIs + +| Method | Path | Permission | +| --- | --- | --- | +| POST | `/api/v1/rewards/{id}/redeem` | `loyalty.rewards.redeem` | +| GET | `/api/v1/rewards/{id}/redemptions` | `loyalty.redemptions.view` | +| GET | `/api/v1/redemptions` | `loyalty.redemptions.view` | +| GET | `/api/v1/redemptions/{id}` | `loyalty.redemptions.view` | +| POST | `/api/v1/redemptions/{id}/fulfill` | `loyalty.rewards.fulfill` | +| POST | `/api/v1/redemptions/{id}/cancel` | `loyalty.rewards.cancel` | + +## Events + +| Event | When | +| --- | --- | +| `loyalty.reward.redeemed` | Redeem (points deducted) | +| `loyalty.reward.fulfilled` | Fulfill | +| `loyalty.reward.redemption_cancelled` | Cancel + refund | + +## Migration + +| Item | Detail | +| --- | --- | +| Alembic | `0004_phase_73_rewards` (down_revision `0003_phase_72_points`) | +| Breaking | None — additive columns + table | + +## Tests + +Command: `cd backend/services/loyalty && pytest -q` → **72 passed** + +## Known Limitations + +- No automatic redemption expiry worker +- No partner fulfillment webhooks (7.9) +- Campaign-driven rewards deferred to 7.4 + +## Next Phase + +Phase 7.4 — Campaign Engine + +## Related Documents + +- [Handover](phase-handover/phase-7-3.md) +- [Phase 7.2](loyalty-phase-7-2.md) +- [Service Snapshot](service-snapshots/loyalty.yaml) +- [ADR-011](architecture/adr/ADR-011.md) diff --git a/docs/loyalty-phase-7-4.md b/docs/loyalty-phase-7-4.md new file mode 100644 index 0000000..cadba5a --- /dev/null +++ b/docs/loyalty-phase-7-4.md @@ -0,0 +1,61 @@ +# Phase 7.4 — Campaign Engine + +| Field | Value | +| --- | --- | +| Identifier | `loyalty-7.4` | +| Status | Complete | +| Module | loyalty | +| Service | `loyalty-service` | +| Version | `0.7.4.0` | +| Database | `loyalty_db` | +| Depends On | Phase 7.3 | +| ADR(s) | ADR-001, ADR-003, ADR-006, ADR-011 | + +## Objective + +Deliver the Campaign Engine: lifecycle transitions, versioned rules, and apply-to-member point grants exclusively via PointEngine — without referral, wallet, or gift cards. + +## Scope + +### In Scope + +- Lifecycle: schedule / activate / pause / resume / expire +- Rules update with `rule_version` bump +- `CampaignApplication` tracking + idempotency + per-member caps +- Apply grants via `PointEngineService.earn(auto_commit=False)` +- Reject soft-delete while campaign ACTIVE +- Migration `0005_phase_74_campaigns` + +### Out of Scope + +- Referral (7.5), Wallet (7.6), Gift cards (7.7), Analytics (7.8), Partner APIs (7.9) + +## APIs + +| Method | Path | Permission | +| --- | --- | --- | +| POST | `/api/v1/campaigns/{id}/schedule` | `loyalty.campaigns.schedule` | +| POST | `/api/v1/campaigns/{id}/activate` | `loyalty.campaigns.activate` | +| POST | `/api/v1/campaigns/{id}/pause` | `loyalty.campaigns.pause` | +| POST | `/api/v1/campaigns/{id}/resume` | `loyalty.campaigns.resume` | +| POST | `/api/v1/campaigns/{id}/expire` | `loyalty.campaigns.expire` | +| POST | `/api/v1/campaigns/{id}/rules` | `loyalty.campaigns.rules.update` | +| POST | `/api/v1/campaigns/{id}/apply` | `loyalty.campaigns.apply` | +| GET | `/api/v1/campaigns/{id}/applications` | `loyalty.campaigns.view` | + +## Events + +`loyalty.campaign.{scheduled,activated,paused,resumed,expired,rules_updated,applied}` + +## Tests + +**87 passed** (`pytest -q`) + +## Next Phase + +Phase 7.5 — Referral Engine + +## Related Documents + +- [Handover](phase-handover/phase-7-4.md) +- [Service Snapshot](service-snapshots/loyalty.yaml) diff --git a/docs/loyalty-phase-7-5.md b/docs/loyalty-phase-7-5.md new file mode 100644 index 0000000..077fb88 --- /dev/null +++ b/docs/loyalty-phase-7-5.md @@ -0,0 +1,35 @@ +# Phase 7.5 — Referral Engine + +| Field | Value | +| --- | --- | +| Identifier | `loyalty-7.5` | +| Status | Complete | +| Module | loyalty | +| Version | `0.7.5.0` | +| Depends On | Phase 7.4 | +| Migration | `0006_phase_75_referral` | + +## Objective + +Referral programs, codes, attributions, and dual-sided point bonuses via PointEngine only. + +## APIs + +`/api/v1/referral-programs`, `/api/v1/referral-codes`, `/api/v1/referrals` (attribute/convert/reward/cancel) + +## Events + +`loyalty.referral.*` (program, code_issued, attributed, converted, rewarded, cancelled) + +## Tests + +**103 passed** + +## Next Phase + +Phase 7.6 — Wallet + +## Related + +- [Handover](phase-handover/phase-7-5.md) +- [Snapshot](service-snapshots/loyalty.yaml) diff --git a/docs/loyalty-phase-7-6.md b/docs/loyalty-phase-7-6.md new file mode 100644 index 0000000..a954fce --- /dev/null +++ b/docs/loyalty-phase-7-6.md @@ -0,0 +1,100 @@ +# Phase 7.6 — Wallet + +| Field | Value | +| --- | --- | +| Identifier | `loyalty-7.6` | +| Status | Complete | +| Module | loyalty | +| Service | `loyalty-service` | +| Version | `0.7.6.0` | +| Database | `loyalty_db` | +| Depends On | Phase 7.5 | +| ADR(s) | ADR-001, ADR-003, ADR-006, ADR-011 | +| Manifest | [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | + +## Objective + +Deliver the Wallet Engine: wallet accounts with an append-only immutable ledger as the sole balance source (credit / debit / adjust / transfer) — without gift cards, analytics, or partner APIs. + +## Scope + +### In Scope + +- `WalletAccount` shell — **no mutable balance column** +- `WalletLedgerEntry` append-only ledger; balance = `SUM(signed amounts)` +- Credit, debit, adjust, transfer (atomic two-leg) +- Idempotency, insufficient-funds rejection, open-account-only mutations +- Permissions, events, validators, migration `0007_phase_76_wallet` +- Tests + documentation + handover + service snapshot + +### Out of Scope + +- Gift Card Platform (7.7) +- Analytics (7.8) +- Public / Partner / Webhook APIs (7.9) +- Final Review (7.10) + +## Ledger Rules + +| Rule | Detail | +| --- | --- | +| Append-only | No UPDATE/DELETE of ledger rows | +| Signed amounts | credit/transfer_in positive; debit/transfer_out negative; adjust signed | +| Open accounts only | Frozen/closed reject mutations | +| Transfer | Same currency; not same account; single commit | +| Balance | Computed from ledger only | + +## APIs + +| Method | Path | Permission | +| --- | --- | --- | +| POST | `/api/v1/wallets` | `loyalty.wallets.create` | +| GET | `/api/v1/wallets` | `loyalty.wallets.view` | +| GET | `/api/v1/wallets/{id}` | `loyalty.wallets.view` | +| GET | `/api/v1/wallets/{id}/balance` | `loyalty.wallets.view` | +| GET | `/api/v1/wallets/{id}/ledger` | `loyalty.wallets.view` | +| PATCH | `/api/v1/wallets/{id}` | `loyalty.wallets.manage` | +| POST | `/api/v1/wallets/{id}/credit` | `loyalty.wallets.credit` | +| POST | `/api/v1/wallets/{id}/debit` | `loyalty.wallets.debit` | +| POST | `/api/v1/wallets/{id}/adjust` | `loyalty.wallets.adjust` | +| POST | `/api/v1/wallets/{id}/transfer` | `loyalty.wallets.transfer` | +| POST | `/api/v1/wallets/{id}/delete` | `loyalty.wallets.delete` | + +## Events + +| Event | When | +| --- | --- | +| `loyalty.wallet.opened` | Account opened | +| `loyalty.wallet.credited` | Credit | +| `loyalty.wallet.debited` | Debit | +| `loyalty.wallet.adjusted` | Adjust | +| `loyalty.wallet.transferred` | Transfer | +| `loyalty.wallet.closed` | Soft close | + +## Migration + +| Item | Detail | +| --- | --- | +| Alembic | `0007_phase_76_wallet` (down_revision `0006_phase_75_referral`) | +| Breaking | None — additive tables | + +## Tests + +Command: `cd backend/services/loyalty && pytest -q` → **114 passed** + +## Known Limitations + +- No payment gateway settlement (credits are ledger-only) +- No gift-card issuance (7.7) +- No scheduled freeze/close workers + +## Next Phase + +Phase 7.7 — Gift Card Platform + +## Related Documents + +- [Handover](phase-handover/phase-7-6.md) +- [Phase 7.5](loyalty-phase-7-5.md) +- [Service Snapshot](service-snapshots/loyalty.yaml) +- [ADR-011](architecture/adr/ADR-011.md) diff --git a/docs/module-registry.md b/docs/module-registry.md index 080ad44..4c957cc 100644 --- a/docs/module-registry.md +++ b/docs/module-registry.md @@ -169,27 +169,27 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned** | Name | Enterprise Loyalty Platform | | Description | Shared loyalty: programs, members, tiers, point accounts, rewards, campaigns; reusable by all business modules | | Owner | Platform | -| Status | Active (Phase 7.1 Membership Engine) | +| Status | Active (Phase 7.6 Wallet) | | Dependencies | Core entitlement (feature-key gate deferred platform-wide; JWT + tenant header enforced) | | Internal Dependencies | None — independent of CRM | | External Dependencies | Platform providers via contracts only (Notification, Analytics, Customer360, AI, ModuleIntegration, CRM, Communication, FileStorage) | | Database Ownership | Sole owner | | Database | `loyalty_db` | | API Prefix | `/api/v1` (service on port 8004) | -| Permission Prefix | `loyalty.*`, `loyalty.programs.*`, `loyalty.tiers.*`, `loyalty.members.*`, `loyalty.point_accounts.*`, `loyalty.rewards.*`, `loyalty.campaigns.*`, `loyalty.audit.*` | -| Events | `loyalty.program.*`, `loyalty.tier.*`, `loyalty.member.*`, `loyalty.point_account.*`, `loyalty.reward.*`, `loyalty.campaign.*` | +| Permission Prefix | `loyalty.*`, `loyalty.programs.*`, `loyalty.tiers.*`, `loyalty.members.*`, `loyalty.point_accounts.*`, `loyalty.points.*`, `loyalty.rewards.*`, `loyalty.redemptions.*`, `loyalty.campaigns.*`, `loyalty.referral_programs.*`, `loyalty.referral_codes.*`, `loyalty.referrals.*`, `loyalty.wallets.*`, `loyalty.audit.*` | +| Events | `loyalty.program.*`, `loyalty.tier.*`, `loyalty.member.*`, `loyalty.point_account.*`, `loyalty.points.*`, `loyalty.reward.*`, `loyalty.campaign.*`, `loyalty.referral.*`, `loyalty.wallet.*` | | Event Producers | Loyalty (transactional outbox) | -| Event Consumers | Future business modules / Customer360 (not consumed in 7.1) | +| Event Consumers | Future business modules / Customer360 (not consumed in 7.6) | | Provider Dependencies | Contracts only — no implementations | | AI Dependencies | Optional via `AIProvider` contract only | -| Documentation | [loyalty-phase-7-0.md](loyalty-phase-7-0.md), [loyalty-phase-7-1.md](loyalty-phase-7-1.md), [phase-handover/phase-7-1.md](phase-handover/phase-7-1.md), [phases/Loyalty](phases/Loyalty/README.md), service README | -| Current Phase | 7.1 complete (Membership Engine); 7.2 not started | -| Version | 0.7.1.0 | +| Documentation | [loyalty-phase-7-0.md](loyalty-phase-7-0.md) … [loyalty-phase-7-6.md](loyalty-phase-7-6.md), [phase-handover/phase-7-6.md](phase-handover/phase-7-6.md), [service-snapshots/loyalty.yaml](service-snapshots/loyalty.yaml), [phases/Loyalty](phases/Loyalty/README.md) | +| Current Phase | 7.6 complete (Wallet); 7.7 next | +| Version | 0.7.6.0 | | Version Compatibility | Designed for Core entitlement; runtime feature-check deferred | -| Migration Version | `0002_phase_71_membership` | +| Migration Version | `0007_phase_76_wallet` | | Tenant Aware | Yes (required) | | Permission Tree | `loyalty.*` (view/manage inheritance supported) | -| Future Plans | Phases 7.2–7.10 (points ledger, rewards, campaigns, coupon/voucher, wallet/gift, partner, integrations, AI/analytics, production readiness) | +| Future Plans | Phases 7.7–7.10 (gift cards, analytics, partner APIs, final review) | --- @@ -197,10 +197,10 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned** | Field | Value | | --- | --- | -| Name | Enterprise Communication Platform | +| Name | Enterprise Communication Platform (Torbat Communication) | | Description | Independent shared messaging: providers, router, SMS, templates, dynamic contacts, queue, delivery tracking, OTP, webhooks, monitoring | | Owner | Platform | -| Status | Active (Phase 8.0–8.10 complete) | +| Status | **Production Ready (SMS MVP)** — Phase 8.0–8.10 complete; feature-complete for current scope | | Dependencies | Core entitlement (optional gate); no other business modules required | | Internal Dependencies | None — independent of CRM / Loyalty / Restaurant | | External Dependencies | SMS providers (Payamak, mock); future email/push/social/voice adapters | @@ -213,14 +213,14 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned** | Event Consumers | Any subscribed business module (via API/events only) | | Provider Dependencies | Payamak (SMS), Mock; stubs for smtp/fcm/whatsapp/telegram/rubika/voice | | AI Dependencies | None | -| Documentation | [communication-phase-8.md](communication-phase-8.md), ADR-012, service README | -| Current Phase | 8.10 complete | -| Version | 0.8.10.0 | +| Documentation | [communication-phase-8.md](communication-phase-8.md), [communication-roadmap.md](communication-roadmap.md), [phase-handover/phase-8.md](phase-handover/phase-8.md), [service-snapshots/communication.yaml](service-snapshots/communication.yaml), ADR-012, service README | +| Current Phase | 8 complete (MVP) | +| Version | 0.8.10.1 | | Version Compatibility | Standalone; Core JWT optional via AUTH_REQUIRED | -| Migration Version | `0001_initial` | +| Migration Version | `0001_initial`, `0002_validation_hardening` | | Tenant Aware | Yes (required) | | Permission Tree | `communication.*` | -| Future Plans | Worker drain, additional channel adapters, optional Core OTP handoff | +| Future Plans | See [communication-roadmap.md](communication-roadmap.md) — Email, Push, WhatsApp, Telegram, Rubika, Voice (not registered phases) | --- @@ -279,7 +279,12 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned** | `sports_center.digital_memberships` | Digital Memberships | Active | 9.2 | | `sports_center.waivers` | Waivers | Active | 9.2 | | `sports_center.member_documents` | Member Documents | Active | 9.2 | -| `sports_center.coaches` | Coaches | Active (shell) | 9.0 | +| `sports_center.coaches` | Coaches / Staff | Active | 9.3 | +| `sports_center.staff_certificates` | Staff Certificates | Active | 9.3 | +| `sports_center.staff_skills` | Staff Skills | Active | 9.3 | +| `sports_center.staff_working_hours` | Staff Working Hours | Active | 9.3 | +| `sports_center.staff_availability` | Staff Availability | Active | 9.3 | +| `sports_center.staff_assignments` | Staff Assignments | Active | 9.3 | | `sports_center.roles` | Sports Roles | Active (shell) | 9.0 | | `sports_center.permissions` | Sports Permissions | Active (shell) | 9.0 | | `sports_center.facilities` | Facilities | Active (shell) | 9.0 | @@ -294,13 +299,24 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned** | `sports_center.events` | Sports Events | Active (shell) | 9.0 | | `sports_center.settings` | Settings | Active (shell) | 9.0 | | `sports_center.audit` | Audit | Active | 9.0 | -| `sports_center.attendance` | Attendance engine | Planned | 9.5 | -| `sports_center.booking` | Booking | Planned | 9.4 | -| `sports_center.equipment` | Equipment | Planned | 9.4 | -| `sports_center.programs` | Programs | Planned | 9.6 | -| `sports_center.workouts` | Workouts | Planned | 9.6 | -| `sports_center.competitions` | Competitions | Planned | 9.7 | -| `sports_center.competitions_events` | Competition events depth | Planned | 9.7 | +| `sports_center.attendance` | Attendance engine | Active | 9.5 | +| `sports_center.booking` | Booking | Active | 9.4 | +| `sports_center.equipment` | Equipment | Active | 9.4 | +| `sports_center.sessions` | Sports Sessions | Active | 9.4 | +| `sports_center.programs` | Training Programs | Active | 9.6 | +| `sports_center.workouts` | Workouts | Active | 9.6 | +| `sports_center.exercises` | Exercises | Active | 9.6 | +| `sports_center.training_goals` | Training Goals | Active | 9.6 | +| `sports_center.measurements` | Measurements | Active | 9.6 | +| `sports_center.competitions` | Competitions | Active | 9.7 | +| `sports_center.competition_teams` | Competition Teams | Active | 9.7 | +| `sports_center.competition_registrations` | Competition Registrations | Active | 9.7 | +| `sports_center.competition_matches` | Competition Matches | Active | 9.7 | +| `sports_center.competition_results` | Competition Results | Active | 9.7 | +| `sports_center.competition_rankings` | Competition Rankings | Active | 9.7 | +| `sports_center.competition_medals` | Competition Medals | Active | 9.7 | +| `sports_center.competition_judges` | Competition Judges | Active | 9.7 | +| `sports_center.competition_certificates` | Competition Certificates | Active | 9.7 | | `sports_center.nutrition` | Nutrition | Planned | Later slice | | `sports_center.mobile` | Mobile | Planned | Cross-cutting | | `sports_center.reports` | Reports | Planned | 9.9 | @@ -336,26 +352,26 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned** | Field | Value | | --- | --- | | Name | Delivery & Fleet Platform (Torbat Driver) | -| Description | Independent logistics platform: drivers, fleet, vehicles, dispatch, routing, tracking, POD, settlement intents, merchant connectors for all verticals | +| Description | Independent logistics platform (Torbat Driver): foundation orgs/hubs/config/providers/routing-engine shells; drivers active; fleet/dispatch in later phases | | Owner | Platform | -| Status | Planned (registration complete; implementation from Phase 10.0) | +| Status | Active (Phase 10.1 drivers) | | Dependencies | Core entitlement; Accounting / CRM / Loyalty / Communication / verticals via API/Events when phases require them | | Internal Dependencies | Modules owned by this service only | -| External Dependencies | Routing engines and fleet providers (adapters); notifications via Communication | +| External Dependencies | Routing engines and fleet providers (adapters); notifications via Communication; Storage file refs for driver docs | | Database Ownership | Sole owner | | Database | `delivery_db` | -| API Prefix | `/api/v1` (planned service on port 8007); `/health`, `/capabilities`, `/metrics` | +| API Prefix | `/api/v1` (service on port 8007); `/health`, `/capabilities`, `/metrics` | | Permission Prefix | `delivery.*` | -| Events | `delivery.driver.*`, `delivery.fleet.*`, `delivery.vehicle.*`, `delivery.dispatch.*`, `delivery.route.*`, `delivery.tracking.*`, `delivery.pod.*`, `delivery.settlement.*`, `delivery.shift.*`, `delivery.zone.*` (grow per phase) | +| Events | `delivery.organization.*`, `delivery.hub.*`, `delivery.external_provider.*`, `delivery.routing_engine.*`, `delivery.configuration.*`, `delivery.setting.*`, `delivery.driver.*` (+ planned fleet/dispatch/...) | | Event Producers | Delivery | | Event Consumers | Accounting, Communication, CRM, Loyalty, Restaurant, Marketplace, Clinic, Sports Center, Analytics (future) | -| Provider Dependencies | External routing/fleet provider interfaces (planned) | +| Provider Dependencies | External routing/fleet provider interfaces (planned); Storage refs for documents | | AI Dependencies | Optional via contracts only (Phase 10.10) | -| Documentation | [delivery-roadmap.md](delivery-roadmap.md), [phases/Delivery](phases/Delivery/README.md), [phase-handover/phase-dp-reg.md](phase-handover/phase-dp-reg.md), [ADR-015](architecture/adr/ADR-015.md) | -| Current Phase | DP-Reg complete; next `delivery-10.0` | -| Version | 0.0.0 | +| Documentation | [delivery-phase-10-1.md](delivery-phase-10-1.md), [phase-handover/phase-10-1.md](phase-handover/phase-10-1.md), [delivery-phase-10-0.md](delivery-phase-10-0.md), [delivery-roadmap.md](delivery-roadmap.md), [phases/Delivery](phases/Delivery/README.md), [ADR-015](architecture/adr/ADR-015.md) | +| Current Phase | 10.1 complete (drivers); next `delivery-10.2` | +| Version | 0.10.1.0 | | Version Compatibility | Requires Core entitlement; integrations per later phases | -| Migration Version | None yet (Phase 10.0) | +| Migration Version | `0002_phase_101_drivers` | | Tenant Aware | Yes (required) | | Permission Tree | `delivery.*` | | Future Plans | Phases 10.0–10.10 per [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | @@ -365,7 +381,13 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned** | Module Key | Name | Status | Phase | | --- | --- | --- | --- | -| `delivery.drivers` | Driver Management | Planned | 10.1 | +| `delivery.organizations` | Delivery Organizations | Active (shell) | 10.0 | +| `delivery.hubs` | Delivery Hubs | Active (shell) | 10.0 | +| `delivery.roles` | Delivery Roles | Active (shell) | 10.0 | +| `delivery.permissions_catalog` | Delivery Permissions Catalog | Active (shell) | 10.0 | +| `delivery.routing_engines` | Routing Engine Registrations | Active (shell) | 10.0 | +| `delivery.settings` | Settings | Active (shell) | 10.0 | +| `delivery.drivers` | Driver Management | Active | 10.1 | | `delivery.fleet` | Fleet Management | Planned | 10.2 | | `delivery.vehicle_types` | Vehicle Types | Planned | 10.2 | | `delivery.vehicles` | Vehicles | Planned | 10.2 | @@ -388,9 +410,9 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned** | `delivery.dispatcher_panel_apis` | Dispatcher Panel APIs | Planned | 10.9 | | `delivery.fleet_analytics` | Fleet Analytics | Planned | 10.10 | | `delivery.ai_hooks` | AI Ready Hooks | Planned | 10.10 | -| `delivery.external_providers` | External Providers Ready | Planned | 10.0+ | -| `delivery.audit` | Audit | Planned | 10.0 | -| `delivery.configuration` | Configuration | Planned | 10.0 | +| `delivery.external_providers` | External Providers Ready | Active (shell) | 10.0 | +| `delivery.audit` | Audit | Active | 10.0 | +| `delivery.configuration` | Configuration | Active (shell) | 10.0 | | Module | Responsibilities | Non-responsibilities | | --- | --- | --- | @@ -405,34 +427,289 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned** --- +## experience + +| Field | Value | +| --- | --- | +| Name | Enterprise Experience Platform (Torbat Pages) | +| Description | Independent experience platform: sites, pages-as-resources, versioned components, themes, layouts, templates, localization, forms/surveys/appointments, SEO/PWA, bundles, widgets for all verticals | +| Owner | Platform | +| Status | Active (Phase 11.0 foundation complete) | +| Dependencies | Core entitlement; Storage / CRM / Loyalty / Communication / Hospitality / Marketplace / Sports / Delivery / AI / Automation via API/Events when phases require them | +| Internal Dependencies | Modules owned by this service only | +| External Dependencies | File Storage for media refs; notifications via Communication | +| Database Ownership | Sole owner | +| Database | `experience_db` | +| API Prefix | `/api/v1` (port 8008); `/health`, `/capabilities`, `/metrics` | +| Permission Prefix | `experience.*` | +| Events | `experience.workspace.*`, `experience.locale_profile.*`, `experience.external_provider.*`, `experience.render_engine.*`, `experience.configuration.*`, `experience.setting.*`, `experience.site.*`, `experience.page.*`, `experience.site_domain.*`, `experience.component.*`, `experience.component_version.*`, `experience.page_component.*`, `experience.theme.*`, `experience.layout.*`, `experience.site_theme.*`, `experience.page_layout.*` (+ planned template/…) | +| Event Producers | Experience | +| Event Consumers | Hospitality, Marketplace, Sports Center, CRM, Loyalty, Communication, Accounting, Clinic, Hotel, Tourism, Analytics (future) | +| Provider Dependencies | Optional theme/component marketplace adapters (planned) | +| AI Dependencies | Optional via contracts only (Phase 11.10) | +| Documentation | [experience-roadmap.md](experience-roadmap.md), [phases/Experience](phases/Experience/README.md), [experience-phase-11-3.md](experience-phase-11-3.md), [phase-handover/phase-11-3.md](phase-handover/phase-11-3.md), [ADR-016](architecture/adr/ADR-016.md) | +| Current Phase | 11.3 complete; next `experience-11.4` | +| Version | 0.11.3.0 | +| Version Compatibility | Requires Core entitlement; integrations per later phases | +| Migration Version | `0002_phase_111_sites_pages` | +| Tenant Aware | Yes (required) | +| Permission Tree | `experience.*` | +| Future Plans | Phases 11.0–11.10 per [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | +| Commercial Product | Torbat Pages | + +### Modules (owned by experience) + +| Module Key | Name | Status | Phase | +| --- | --- | --- | --- | +| `experience.workspaces` | Workspaces | Complete | 11.0 | +| `experience.locale_profiles` | Locale Profiles | Complete | 11.0 | +| `experience.external_providers` | External Providers | Complete | 11.0 | +| `experience.render_engines` | Render Engines | Complete | 11.0 | +| `experience.audit` | Audit | Complete | 11.0 | +| `experience.configuration` | Configuration | Complete | 11.0 | +| `experience.sites` | Sites | Complete | 11.1 | +| `experience.pages` | Page Resources | Complete | 11.1 | +| `experience.page_types` | Page Types | Complete | 11.1 | +| `experience.domains` | Site Domains | Complete | 11.1 | +| `experience.components` | Components | Complete | 11.2 | +| `experience.component_versions` | Component Versions | Complete | 11.2 | +| `experience.page_component_placements` | Page Component Placements | Complete | 11.2 | +| `experience.themes` | Themes | Complete | 11.3 | +| `experience.layouts` | Layouts | Complete | 11.3 | +| `experience.site_theme_assignments` | Site Theme Assignments | Complete | 11.3 | +| `experience.page_layout_assignments` | Page Layout Assignments | Complete | 11.3 | +| `experience.templates` | Templates | Planned | 11.4 | +| `experience.locales` | Localization | Planned | 11.5 | +| `experience.media_refs` | Media References | Planned | 11.5 | +| `experience.forms` | Forms | Planned | 11.6 | +| `experience.surveys` | Surveys | Planned | 11.6 | +| `experience.appointments` | Appointment Pages | Planned | 11.6 | +| `experience.publishing` | Publishing | Planned | 11.7 | +| `experience.seo` | SEO | Planned | 11.7 | +| `experience.pwa` | PWA Readiness | Planned | 11.7 | +| `experience.bundles` | Capability Bundles | Planned | 11.8 | +| `experience.feature_toggles` | Feature Toggles | Planned | 11.8 | +| `experience.widgets` | Widgets | Planned | 11.9 | +| `experience.consumer_connectors` | Consumer Connectors | Planned | 11.9 | +| `experience.analytics` | Analytics | Planned | 11.10 | +| `experience.ai_hooks` | AI Content Hooks | Planned | 11.10 | + +| Module | Responsibilities | Non-responsibilities | +| --- | --- | --- | +| Sites / Pages | Site and page resources + lifecycle | Vertical menu/product source of truth | +| Components | Versioned building blocks | Binary asset storage | +| Themes / Layouts | Replaceable themes; dynamic layouts | Core white-label tenant brand ownership | +| Templates | Starter packs and instantiation | Frontend builder UI | +| Forms / Surveys | Form/survey definitions + submission shells | Communication provider ownership | +| Publishing / SEO / PWA | Publish workflow + SEO/PWA shells | Nginx/DNS/SSL edge ownership | +| Bundles / Toggles | Commercial packs + feature flags | Core plan/subscription engine | +| Widgets / Connectors | Embed + vertical integration contracts | Owning CRM/Hospitality aggregates | +| Analytics / AI | Shells + optional AI contracts | Mandatory AI for publish | + +--- + ## restaurant | Field | Value | | --- | --- | -| Name | Restaurant / Cafe | -| Description | Digital menu, tables, orders, kitchen; loyalty via Loyalty service | -| Owner | TBD | -| Status | Scaffolded | -| Dependencies | Core entitlement, white-label host; Loyalty (API/Events) | -| Internal Dependencies | Optional accounting postings via events | -| External Dependencies | Payment provider (future) | +| Name | Restaurant / Cafe (historical) | +| Description | Superseded by Hospitality Platform — see hospitality | +| Owner | Platform | +| Status | Superseded | +| Documentation | [phases/Restaurant](phases/Restaurant/README.md), [hospitality](#hospitality) | +| Current Phase | hospitality-12.0 | +| Version | 0.12.0.0 | +| Notes | Pointer only; runtime is `hospitality` | + +--- + +## hospitality + +| Field | Value | +| --- | --- | +| Name | Hospitality Platform | +| Description | Independent F&B platform (Torbat Food): venues, digital menu catalog, tables, QR menu & QR ordering shells, table service & reservations, bundle licensing, feature toggles; future POS/kitchen/connectors | +| Owner | Platform | +| Status | Active (Phase 12.8 Analytics) | +| Dependencies | Core entitlement; Accounting / CRM / Loyalty / Communication / Delivery / Experience via API/Events when phases require them | +| Internal Dependencies | None — independent of CRM / Loyalty / Delivery / Experience DBs | +| External Dependencies | Payment provider (future); Storage refs for media | | Database Ownership | Sole owner | -| Database | `restaurant_db` | -| API Prefix | `/api/v1` | -| Permission Prefix | `restaurant.*` | -| Events | `order.*` (planned) | -| Event Producers | Restaurant | -| Event Consumers | Accounting, notification | -| Provider Dependencies | Payment (planned) | +| Database | `hospitality_db` | +| API Prefix | `/api/v1` (service on port 8009); `/health`, `/capabilities` | +| Permission Prefix | `hospitality.*` | +| Events | `hospitality.venue.*`, `hospitality.menu.*`, `hospitality.allergen.*`, `hospitality.modifier_*`, `hospitality.availability_window.*`, `hospitality.menu_media_ref.*`, `hospitality.menu_localization.*`, `hospitality.qr_code.*`, `hospitality.qr_menu_session.*`, `hospitality.qr_ordering_session.*`, `hospitality.cart.*`, `hospitality.cart_line.*`, `hospitality.reservation.*`, `hospitality.table_assignment.*`, `hospitality.service_request.*`, `hospitality.waitlist_entry.*`, `hospitality.pos_register.*`, `hospitality.pos_shift.*`, `hospitality.pos_ticket.*`, `hospitality.pos_ticket_line.*`, `hospitality.pos_payment.*`, `hospitality.pos_discount.*`, `hospitality.pos_tax_line.*`, `hospitality.pos_floor_plan.*`, `hospitality.pos_station.*`, `hospitality.kitchen_station.*`, `hospitality.kitchen_ticket.*`, `hospitality.kitchen_ticket_item.*`, `hospitality.connector_registration.*`, `hospitality.connector_dispatch.*`, `hospitality.analytics_report_definition.*`, `hospitality.analytics_snapshot.*`, `hospitality.tenant_bundle.*`, `hospitality.feature_toggle.*`, … | +| Event Producers | Hospitality | +| Event Consumers | Accounting, CRM, Loyalty, Communication, Delivery, Experience (subscribed) | +| Provider Dependencies | Contracts only through 12.8 (mock implementations for local dispatch simulation) | | AI Dependencies | Optional | -| Documentation | [phases/Restaurant](phases/Restaurant/README.md) | -| Current Phase | Candidate first business module after white-label | -| Version | 0.0.0 | -| Version Compatibility | Core + public tenant site | -| Migration Version | None | -| Tenant Aware | Yes | -| Permission Tree | `restaurant.*` | -| Future Plans | Guest ordering on tenant domain | +| Documentation | [hospitality-phase-12-8.md](hospitality-phase-12-8.md), [hospitality-phase-12-7.md](hospitality-phase-12-7.md), [hospitality-phase-12-6.md](hospitality-phase-12-6.md), [hospitality-phase-12-5.md](hospitality-phase-12-5.md), [hospitality-phase-12-4.md](hospitality-phase-12-4.md), [hospitality-phase-12-3.md](hospitality-phase-12-3.md), [hospitality-phase-12-2.md](hospitality-phase-12-2.md), [hospitality-phase-12-1.md](hospitality-phase-12-1.md), [hospitality-phase-12-0.md](hospitality-phase-12-0.md), [hospitality-roadmap.md](hospitality-roadmap.md), ADR-017, service README | +| Current Phase | 12.8 complete | +| Version | 0.12.8.0 | +| Version Compatibility | Standalone; Core JWT optional via AUTH_REQUIRED | +| Migration Version | `0009_phase_128_analytics` | +| Tenant Aware | Yes (required) | +| Permission Tree | `hospitality.*` | +| Commercial Product | Torbat Food | +| Future Plans | AI Assistant (12.9), Enterprise Validation (12.10) | + +--- + +## healthcare + +| Field | Value | +| --- | --- | +| Name | Healthcare Platform (Torbat Health) | +| Description | Independent healthcare platform: clinics, doctors, patients, appointment engine, doctor panel, clinic management, patient portal, medical records, pharmacy network, Delivery integration | +| Owner | Platform | +| Status | **Planned** (HC-Reg documentation complete; implementation not started) | +| Dependencies | Core entitlement; Identity / Communication / CRM / Loyalty / Accounting / Delivery / File Storage via API/Events when phases require them | +| Internal Dependencies | Modules owned by this service only | +| External Dependencies | Delivery platform (Phase 13.7); Storage refs for documents/imaging | +| Database Ownership | Sole owner (planned) | +| Database | `healthcare_db` | +| API Prefix | `/api/v1` (service on port **8010** reserved); `/health`, `/capabilities`, `/metrics` | +| Permission Prefix | `healthcare.*` | +| Events | `healthcare.clinic.*`, `healthcare.doctor.*`, `healthcare.patient.*`, `healthcare.appointment.*`, `healthcare.visit_session.*`, `healthcare.medical_record.*`, `healthcare.prescription_order.*`, `healthcare.delivery_job_intent.*` (+ planned) | +| Event Producers | Healthcare (planned) | +| Event Consumers | Communication, Delivery, CRM, Loyalty, Accounting, Experience (future) | +| Provider Dependencies | Contracts only — Communication, Delivery, Storage, CRM, Accounting, AI | +| AI Dependencies | Optional via contracts only (future) | +| Documentation | [healthcare-roadmap.md](healthcare-roadmap.md), [phases/Healthcare](phases/Healthcare/README.md) | +| Current Phase | HC-Reg complete; next `healthcare-13-0` | +| Version | 0.0.0 (planned foundation `0.13.0.0`) | +| Version Compatibility | Requires Core entitlement | +| Migration Version | None (planned `0001_initial` at 13.0) | +| Tenant Aware | Yes (required) | +| Permission Tree | `healthcare.*` | +| Future Plans | Phases 13.0–13.7 per [healthcare-roadmap.md](healthcare-roadmap.md) | +| Commercial Product | Torbat Health | + +### Modules (owned by healthcare — planned) + +| Module Key | Name | Status | Phase | +| --- | --- | --- | --- | +| `healthcare.clinics` | Clinics | Planned | 13.0 | +| `healthcare.branches` | Branches | Planned | 13.0 | +| `healthcare.departments` | Departments | Planned | 13.0 | +| `healthcare.doctors` | Doctors | Planned | 13.0 | +| `healthcare.patients` | Patients | Planned | 13.0 | +| `healthcare.roles` | Healthcare Roles | Planned | 13.0 | +| `healthcare.permissions_catalog` | Permissions Catalog | Planned | 13.0 | +| `healthcare.configuration` | Configuration | Planned | 13.0 | +| `healthcare.settings` | Settings | Planned | 13.0 | +| `healthcare.external_providers` | External Providers | Planned | 13.0 | +| `healthcare.audit` | Audit | Planned | 13.0 | +| `healthcare.schedules` | Schedules | Planned | 13.1 | +| `healthcare.availability` | Availability | Planned | 13.1 | +| `healthcare.appointment_types` | Appointment Types | Planned | 13.1 | +| `healthcare.appointments` | Appointment Engine | Planned | 13.1 | +| `healthcare.waiting_list` | Waiting List | Planned | 13.1 | +| `healthcare.doctor_panel` | Doctor Panel | Planned | 13.2 | +| `healthcare.visit_sessions` | Visit Sessions | Planned | 13.2 | +| `healthcare.visit_notes` | Visit Notes | Planned | 13.2 | +| `healthcare.clinic_services` | Clinic Services Catalog | Planned | 13.3 | +| `healthcare.staff_assignments` | Staff Assignments | Planned | 13.3 | +| `healthcare.clinic_policies` | Clinic Policies | Planned | 13.3 | +| `healthcare.patient_portal` | Patient Portal | Planned | 13.4 | +| `healthcare.patient_documents` | Patient Documents | Planned | 13.4 | +| `healthcare.medical_records` | Medical Records | Planned | 13.5 | +| `healthcare.encounters` | Encounters | Planned | 13.5 | +| `healthcare.clinical_lists` | Conditions / Allergies / Medications | Planned | 13.5 | +| `healthcare.pharmacies` | Pharmacy Network | Planned | 13.6 | +| `healthcare.prescriptions` | Prescriptions | Planned | 13.6 | +| `healthcare.prescription_orders` | Prescription Orders | Planned | 13.6 | +| `healthcare.delivery_integration` | Delivery Integration | Planned | 13.7 | +| `healthcare.delivery_job_intents` | Delivery Job Intents | Planned | 13.7 | + +| Module | Responsibilities | Non-responsibilities | +| --- | --- | --- | +| Clinics / Branches | Healthcare org structure | Core tenant/workspace admin | +| Doctors / Patients | Clinical actor profiles (refs to Identity) | Identity user admin | +| Appointments | Scheduling and lifecycle | Communication provider ownership | +| Doctor Panel | Visit queue and session shells | Full EHR legal archive | +| Clinic Management | Services catalog, staff, policies | Accounting postings | +| Patient Portal | Patient self-service APIs | Frontend UI ownership | +| Medical Records | Encounters and clinical lists | National HIE / FHIR mandate | +| Pharmacy Network | Prescription routing and fulfillment status | Drug inventory accounting | +| Delivery Integration | Job intents to Delivery platform | Drivers, fleet, dispatch, routing | + +--- + +## beauty_business + +| Field | Value | +| --- | --- | +| Name | Beauty Business Platform (Torbat Beauty) | +| Description | Independent beauty & wellness platform: salons, barbershops, beauty clinics, laser centers, skin-care centers, hair-treatment centers, spa & wellness; booking, salon management, service catalog, customers, packages, staff commission, marketing/Loyalty integration | +| Owner | Platform | +| Status | **Planned** (BB-Reg documentation complete; implementation not started) | +| Dependencies | Core entitlement; Identity / Communication / CRM / Loyalty / Accounting / Delivery / Experience via API/Events when phases require them | +| Internal Dependencies | Modules owned by this service only | +| External Dependencies | Storage refs for media/documents; Loyalty and Communication via client contracts | +| Database Ownership | Sole owner (planned) | +| Database | `beauty_business_db` | +| API Prefix | `/api/v1` (service on port **8011** reserved); `/health`, `/capabilities`, `/metrics` | +| Permission Prefix | `beauty_business.*` | +| Events | `beauty_business.organization.*`, `beauty_business.branch.*`, `beauty_business.appointment.*`, `beauty_business.service.*`, `beauty_business.customer.*`, `beauty_business.package.*`, `beauty_business.membership.*`, `beauty_business.staff.*`, `beauty_business.commission.*`, `beauty_business.marketing.*` (+ planned) | +| Event Producers | Beauty Business (planned) | +| Event Consumers | Communication, Loyalty, CRM, Accounting, Experience, Delivery (future) | +| Provider Dependencies | Contracts only — Communication, Loyalty, CRM, Accounting, Experience, Delivery, Storage, AI | +| AI Dependencies | Optional via contracts only (future) | +| Documentation | [beauty-business-roadmap.md](beauty-business-roadmap.md), [phases/BeautyBusiness](phases/BeautyBusiness/README.md) | +| Current Phase | BB-Reg complete; next `beauty-business-14-0` | +| Version | 0.0.0 (planned foundation `0.14.0.0`) | +| Version Compatibility | Requires Core entitlement | +| Migration Version | None (planned `0001_initial` at 14.0) | +| Tenant Aware | Yes (required) | +| Permission Tree | `beauty_business.*` | +| Future Plans | Phases 14.0–14.7 per [beauty-business-roadmap.md](beauty-business-roadmap.md); post-14.7 retail POS, analytics, mobile | +| Commercial Product | Torbat Beauty | + +**Independence from Healthcare:** Beauty Business and Healthcare are sibling modules. Separate documentation, phases, frontend (`/beauty` vs `/healthcare`), dashboards, permissions, navigation, and branding. Shared Core platforms only via API/events. + +### Modules (owned by beauty_business — planned) + +| Module Key | Name | Status | Phase | +| --- | --- | --- | --- | +| `beauty_business.organizations` | Beauty Organizations | Planned | 14.0 | +| `beauty_business.branches` | Branches | Planned | 14.0 | +| `beauty_business.roles` | Beauty Roles | Planned | 14.0 | +| `beauty_business.permissions_catalog` | Permissions Catalog | Planned | 14.0 | +| `beauty_business.configuration` | Configuration | Planned | 14.0 | +| `beauty_business.settings` | Settings | Planned | 14.0 | +| `beauty_business.external_providers` | External Providers | Planned | 14.0 | +| `beauty_business.audit` | Audit | Planned | 14.0 | +| `beauty_business.schedules` | Staff Schedules | Planned | 14.1 | +| `beauty_business.appointments` | Booking Engine | Planned | 14.1 | +| `beauty_business.waiting_list` | Waiting List | Planned | 14.1 | +| `beauty_business.rooms` | Treatment Rooms | Planned | 14.2 | +| `beauty_business.stations` | Stations | Planned | 14.2 | +| `beauty_business.branch_policies` | Branch Policies | Planned | 14.2 | +| `beauty_business.service_categories` | Service Categories | Planned | 14.3 | +| `beauty_business.services` | Beauty Services | Planned | 14.3 | +| `beauty_business.service_variants` | Service Variants | Planned | 14.3 | +| `beauty_business.customers` | Customer Management | Planned | 14.4 | +| `beauty_business.customer_preferences` | Customer Preferences | Planned | 14.4 | +| `beauty_business.visit_history` | Visit History | Planned | 14.4 | +| `beauty_business.packages` | Packages | Planned | 14.5 | +| `beauty_business.memberships` | Memberships | Planned | 14.5 | +| `beauty_business.staff` | Staff Profiles | Planned | 14.6 | +| `beauty_business.commissions` | Commissions | Planned | 14.6 | +| `beauty_business.marketing` | Marketing Hooks | Planned | 14.7 | +| `beauty_business.loyalty_integration` | Loyalty Integration | Planned | 14.7 | +| `beauty_business.communication_integration` | Communication Integration | Planned | 14.7 | + +| Module | Responsibilities | Non-responsibilities | +| --- | --- | --- | +| Organizations / Branches | Beauty business org structure | Core tenant/workspace admin | +| Booking | Appointments and waiting list | Communication reminder delivery | +| Salon Management | Rooms, stations, branch policies | Healthcare clinical facilities | +| Service Catalog | Beauty services and variants | CRM product catalog; clinical service codes | +| Customers | Beauty customer context (CRM refs) | CRM Contact ownership; Healthcare patients | +| Packages / Memberships | Prepaid sessions and plans | Loyalty ledger; Accounting subscriptions | +| Staff / Commission | Stylists, rules, accrual shells | Identity admin; Accounting payroll engine | +| Marketing / Loyalty | Integration refs and dispatch | Loyalty PointEngine; Communication providers | --- @@ -503,13 +780,13 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned** | Field | Value | | --- | --- | | Name | Website Builder | -| Description | Sites, pages, blocks, forms, menus, media | +| Description | Historical scaffold for sites/pages/blocks — prefer Enterprise Experience Platform (`experience` / Torbat Pages) for new work (ADR-016) | | Owner | TBD | -| Status | Scaffolded | +| Status | Scaffolded (deferred; prefer `experience`) | | Dependencies | file_storage | | Internal Dependencies | — | | External Dependencies | S3 | -| Database Ownership | Sole owner | +| Database Ownership | Sole owner (historical scaffold) | | Database | `website_builder_db` | | API Prefix | `/api/v1` | | Permission Prefix | `website_builder.*` | @@ -518,14 +795,14 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned** | Event Consumers | — | | Provider Dependencies | S3 | | AI Dependencies | Optional copy assist | -| Documentation | service README | -| Current Phase | Not started | +| Documentation | service README; [ADR-016](architecture/adr/ADR-016.md) | +| Current Phase | Not started — prefer Experience Phases 11.0+ | | Version | 0.0.0 | | Version Compatibility | Core | | Migration Version | None | | Tenant Aware | Yes | | Permission Tree | `website_builder.*` | -| Future Plans | Tie into white-label public sites | +| Future Plans | Do not expand; migrate capability intent to `experience` | --- @@ -782,9 +1059,17 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned** - [Provider Registry](provider-registry.md) - [Roadmap](roadmap.md) - [Architecture Overview](architecture/architecture.md) -- [AI Development Framework](ai-framework/README.md) +- [AI / Enterprise Development Framework](ai-framework/README.md) - [Phase Manifest](ai-framework/phase-manifest.yaml) - [Service Manifest](ai-framework/service-manifest.yaml) - [ADR-013](architecture/adr/ADR-013.md) +- [ADR-018](architecture/adr/ADR-018.md) +- [ADR-019](architecture/adr/ADR-019.md) - [Sports Center Roadmap](sports-center-roadmap.md) - [ADR-014](architecture/adr/ADR-014.md) +- [Delivery Roadmap](delivery-roadmap.md) +- [ADR-015](architecture/adr/ADR-015.md) +- [Experience Roadmap](experience-roadmap.md) +- [Healthcare Roadmap](healthcare-roadmap.md) +- [Beauty Business Roadmap](beauty-business-roadmap.md) +- [ADR-016](architecture/adr/ADR-016.md) diff --git a/docs/next-steps.md b/docs/next-steps.md index a9eb235..4ca2224 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -1,40 +1,63 @@ # Next Steps -> Immediate next milestone only. History → [progress.md](progress.md). Future map → [roadmap.md](roadmap.md). +> Immediate next milestone only. History → [progress.md](progress.md). -## Current Milestone: Phase 7.2 — Loyalty Point Engine +## Current Milestone: Phase 7.7 — Gift Card Platform (Loyalty) -Follow the permanent [AI Development Framework](ai-framework/README.md) ([cursor-guidelines.md](ai-framework/cursor-guidelines.md), [development-loop.md](ai-framework/development-loop.md), [quality-gates.md](ai-framework/quality-gates.md)). +Follow the permanent [AI Development Framework](ai-framework/README.md). ### Why -Phase 7.1 Membership Engine is complete (lifecycle state machine, history, cascade policy, APIs/events). +Phase 7.6 Wallet is complete. Gift cards are the next Loyalty engine. -Mandatory entry: [phase-handover/phase-7-1.md](phase-handover/phase-7-1.md). +Mandatory entry: [phase-handover/phase-7-6.md](phase-handover/phase-7-6.md). ### Scope -1. Immutable point ledger as sole balance source (no mutable balance column) -2. Earn / redeem / adjust / expire ledger entries with audit + outbox events -3. PointAccount remains a shell linked to member/program — balances derived from ledger -4. Do **not** implement rewards redemption engine (7.3) or campaign engine (7.4) +1. Gift card issue / activate / redeem / void (or equivalent lifecycle) +2. Keep ledger immutability (wallet and/or dedicated gift-card ledger) +3. Permissions + events; do **not** build analytics/partner APIs yet ### Exit Criteria -- [ ] Ledger APIs + validators + tests green -- [ ] Docs: `docs/loyalty-phase-7-2.md` + progress/registry + handover -- [ ] Quality gates + phase handover per AI framework +- [ ] Gift card APIs + validators + tests green +- [ ] Docs + progress/registry + handover + service snapshot +- [ ] Quality gates per AI framework ### After This Milestone -Phase 7.3 — Rewards & Redemption. +Phase 7.8 — Analytics. + +--- + +## Recently Completed: Healthcare Platform (Phases 13.0–13.7) ✅ + +Torbat Health is fully implemented. Next work is **post-13.7** (not registered phases): + +- Live Delivery platform connector (replace mock when Delivery 10.9+) +- National e-prescription / HIE connectors +- Dedicated frontend patient mobile app + +Handover: [phase-handover/phase-13-7.md](phase-handover/phase-13-7.md) · Snapshot: [service-snapshots/healthcare.yaml](service-snapshots/healthcare.yaml) + +--- + +## Registered (Not Current Milestone) + +| Registration | Status | Next implementation phase | +| --- | --- | --- | +| [Healthcare HC-Reg](healthcare-roadmap.md) | **Phases 13.0–13.7 complete** | Post-13.7 enhancements (unregistered) | +| [Beauty Business BB-Reg](beauty-business-roadmap.md) | **Phases 14.0–14.7 complete** | Post-14.7 enhancements (unregistered) | ## Related Documents - [Progress](progress.md) -- [Phase Handover 7.1](phase-handover/phase-7-1.md) -- [Loyalty Phase 7.1](loyalty-phase-7-1.md) -- [Loyalty Phase 7.0](loyalty-phase-7-0.md) -- [Phases / Loyalty](phases/Loyalty/README.md) +- [Phase Handover 7.6](phase-handover/phase-7-6.md) +- [Phase Handover 14.7](phase-handover/phase-14-7.md) +- [Phase Handover 13.7](phase-handover/phase-13-7.md) +- [Healthcare Roadmap](healthcare-roadmap.md) +- [Service Snapshot — Healthcare](service-snapshots/healthcare.yaml) +- [Loyalty Phase 7.6](loyalty-phase-7-6.md) +- [Beauty Business Roadmap](beauty-business-roadmap.md) +- [Service Snapshot — Beauty Business](service-snapshots/beauty-business.yaml) - [AI Development Framework](ai-framework/README.md) -- [ADR-011](architecture/adr/ADR-011.md) diff --git a/docs/phase-handover/phase-7-2.md b/docs/phase-handover/phase-7-2.md new file mode 100644 index 0000000..fbd3be0 --- /dev/null +++ b/docs/phase-handover/phase-7-2.md @@ -0,0 +1,101 @@ +# Phase Handover — Loyalty 7.2 Point Engine + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | loyalty-7.2 | +| Title | Point Engine (immutable ledger) | +| Status | Complete | +| Service(s) | loyalty (`loyalty-service`, port 8004) | +| Version | 0.7.2.0 | +| Date | 2026-07-26 | +| ADR(s) | ADR-001, ADR-003, ADR-006, ADR-011 | + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| PointLedgerEntry | `app/models/ledger.py` | Append-only pattern for wallet/gift (7.6/7.7) | +| PointLedgerRepository | `app/repositories/ledger.py` | Balance sum + filtered list | +| PointEngineService | `app/services/point_engine.py` | Earn/redeem/adjust/expire orchestration | +| Ledger validators | `app/validators/ledger.py` | Signed amounts + open-account checks | + +## Public APIs + +| Method | Path | Auth / Permission | Notes | +| --- | --- | --- | --- | +| GET | `/api/v1/point-accounts/{id}/balance` | `loyalty.points.view` | Computed balance | +| GET | `/api/v1/point-accounts/{id}/ledger` | `loyalty.points.view` | Paginated entries | +| POST | `/api/v1/point-accounts/{id}/earn` | `loyalty.points.earn` | Idempotent optional | +| POST | `/api/v1/point-accounts/{id}/redeem` | `loyalty.points.redeem` | Rejects insufficient | +| POST | `/api/v1/point-accounts/{id}/adjust` | `loyalty.points.adjust` | Signed delta | +| POST | `/api/v1/point-accounts/{id}/expire-points` | `loyalty.points.expire` | Debit expire | + +## Events + +| Event type | Domain / Integration | Payload summary | Version | +| --- | --- | --- | --- | +| `loyalty.points.earned` | Domain | account, amount, balance_after | 1 | +| `loyalty.points.redeemed` | Domain | account, amount, balance_after | 1 | +| `loyalty.points.adjusted` | Domain | account, amount, balance_after | 1 | +| `loyalty.points.expired` | Domain | account, amount, balance_after | 1 | + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| Ledger entry types | Add enum + validators + tests | Mutate existing rows | +| Balance source | Always SUM(ledger) | Add mutable balance column | +| Reward redemption | Call PointEngine from 7.3 | Bypass ledger for rewards | + +## Known Limitations + +- No scheduled expire worker +- No per-lot FIFO expiration +- Member transfer does not move ledger rows + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | `0003_phase_72_points` | +| Upgrade steps | `alembic upgrade head` in loyalty service | +| Downgrade support | Yes (drops table) | +| Data backfill | None | +| Breaking changes | None (additive) | + +## Dependencies + +| Dependency | Type | Required for | +| --- | --- | --- | +| loyalty-7.1 | Phase | Membership + point accounts | +| shared-lib | Library | Events, security, exceptions | + +## Next Phase Entry + +1. This handover + [loyalty-phase-7-2.md](../loyalty-phase-7-2.md) +2. Service snapshot + manifests +3. Build Rewards Engine on top of PointEngine redeem — do not mutate balances directly +4. Suggested next: `loyalty-7.3` Rewards Engine + +| Field | Value | +| --- | --- | +| Recommended next phase | loyalty-7.3 | +| Blockers for next phase | None | +| Entry checklist | Read 7.2 handover; keep ledger immutable; reuse PointEngineService | + +## Completion Sign-Off + +- [x] Quality gates passed +- [x] Tests green (61) +- [x] Documentation updated +- [x] Progress / next-steps / registries updated +- [x] Service Snapshot regenerated +- [x] No TODO for claimed deliverables + +## Related Documents + +- [loyalty-phase-7-2.md](../loyalty-phase-7-2.md) +- [loyalty-phase-7-1.md](../loyalty-phase-7-1.md) +- [ADR-011](../architecture/adr/ADR-011.md) diff --git a/docs/phase-handover/phase-7-3.md b/docs/phase-handover/phase-7-3.md new file mode 100644 index 0000000..9cfa93f --- /dev/null +++ b/docs/phase-handover/phase-7-3.md @@ -0,0 +1,95 @@ +# Phase Handover — Loyalty 7.3 Rewards Engine + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | loyalty-7.3 | +| Title | Rewards Engine | +| Status | Complete | +| Service(s) | loyalty (`loyalty-service`, port 8004) | +| Version | 0.7.3.0 | +| Date | 2026-07-26 | +| ADR(s) | ADR-001, ADR-003, ADR-006, ADR-011 | + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| RewardRedemption | `app/models/rewards.py` | Lifecycle + ledger linkage pattern | +| RewardsEngineService | `app/services/rewards_engine.py` | Compose with PointEngine via `auto_commit=False` | +| PointEngine auto_commit | `app/services/point_engine.py` | Required for multi-aggregate transactions | + +## Public APIs + +| Method | Path | Auth / Permission | Notes | +| --- | --- | --- | --- | +| POST | `/api/v1/rewards/{id}/redeem` | `loyalty.rewards.redeem` | Debits points; pending | +| GET | `/api/v1/rewards/{id}/redemptions` | `loyalty.redemptions.view` | Per-reward list | +| GET | `/api/v1/redemptions` | `loyalty.redemptions.view` | Filtered list | +| GET | `/api/v1/redemptions/{id}` | `loyalty.redemptions.view` | Detail | +| POST | `/api/v1/redemptions/{id}/fulfill` | `loyalty.rewards.fulfill` | pending→fulfilled | +| POST | `/api/v1/redemptions/{id}/cancel` | `loyalty.rewards.cancel` | Refunds points | + +## Events + +| Event type | Domain / Integration | Payload summary | Version | +| --- | --- | --- | --- | +| `loyalty.reward.redeemed` | Domain | redemption + reward + points | 1 | +| `loyalty.reward.fulfilled` | Domain | redemption id | 1 | +| `loyalty.reward.redemption_cancelled` | Domain | redemption + refund | 1 | + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| Eligibility | Extend validators | Bypass PointEngine for paid rewards | +| Campaign grants | Call RewardsEngine from 7.4 | Direct balance mutation | +| Inventory | stock_limit / max_per_member | Negative redeemed_count | + +## Known Limitations + +- No scheduled redemption expiry +- No external partner fulfillment callbacks (7.9) + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | `0004_phase_73_rewards` | +| Upgrade steps | `alembic upgrade head` | +| Downgrade support | Yes | +| Breaking changes | None (additive) | + +## Dependencies + +| Dependency | Type | Required for | +| --- | --- | --- | +| loyalty-7.2 | Phase | PointEngine ledger | +| shared-lib | Library | Events, security, exceptions | + +## Next Phase Entry + +1. This handover + [loyalty-phase-7-3.md](../loyalty-phase-7-3.md) +2. Build Campaign Engine; grant points/rewards only via PointEngine / RewardsEngine +3. Suggested next: `loyalty-7.4` Campaign Engine + +| Field | Value | +| --- | --- | +| Recommended next phase | loyalty-7.4 | +| Blockers for next phase | None | +| Entry checklist | Keep ledger immutable; reuse RewardsEngine + PointEngine | + +## Completion Sign-Off + +- [x] Quality gates passed +- [x] Tests green (72) +- [x] Documentation updated +- [x] Progress / next-steps / registries updated +- [x] Service Snapshot regenerated + +## Related Documents + +- [loyalty-phase-7-3.md](../loyalty-phase-7-3.md) +- [loyalty-phase-7-2.md](../loyalty-phase-7-2.md) +- [ADR-011](../architecture/adr/ADR-011.md) diff --git a/docs/phase-handover/phase-7-4.md b/docs/phase-handover/phase-7-4.md new file mode 100644 index 0000000..3598245 --- /dev/null +++ b/docs/phase-handover/phase-7-4.md @@ -0,0 +1,66 @@ +# Phase Handover — Loyalty 7.4 Campaign Engine + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | loyalty-7.4 | +| Title | Campaign Engine | +| Status | Complete | +| Service(s) | loyalty (`loyalty-service`, port 8004) | +| Version | 0.7.4.0 | +| Date | 2026-07-26 | +| ADR(s) | ADR-001, ADR-003, ADR-006, ADR-011 | + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| CampaignEngineService | `app/services/campaign_engine.py` | Lifecycle + apply grants | +| CampaignApplication | `app/models/campaigns.py` | Application tracking pattern | + +## Public APIs + +Lifecycle + rules + apply + applications list under `/api/v1/campaigns/{id}/…` + +## Events + +`loyalty.campaign.scheduled|activated|paused|resumed|expired|rules_updated|applied` + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| Rules JSON | Extend validators + grant math | Mutate balances directly | +| Referral bonuses | Call CampaignEngine or PointEngine from 7.5 | Bypass application tracking | + +## Known Limitations + +- No scheduled auto-activate/expire worker +- Rules are JSON document, not a visual rule builder + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic | `0005_phase_74_campaigns` | +| Breaking | None | + +## Next Phase Entry + +| Field | Value | +| --- | --- | +| Recommended next phase | loyalty-7.5 | +| Blockers | None | +| Entry checklist | Reuse PointEngine for referral rewards | + +## Completion Sign-Off + +- [x] Tests green (87) +- [x] Docs + snapshot + manifests +- [x] Quality gates + +## Related Documents + +- [loyalty-phase-7-4.md](../loyalty-phase-7-4.md) +- [loyalty-phase-7-3.md](../loyalty-phase-7-3.md) diff --git a/docs/phase-handover/phase-7-5.md b/docs/phase-handover/phase-7-5.md new file mode 100644 index 0000000..8ffebd0 --- /dev/null +++ b/docs/phase-handover/phase-7-5.md @@ -0,0 +1,36 @@ +# Phase Handover — Loyalty 7.5 Referral Engine + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | loyalty-7.5 | +| Title | Referral Engine | +| Status | Complete | +| Version | 0.7.5.0 | +| Date | 2026-07-26 | + +## Reusable Components + +| Component | Location | +| --- | --- | +| ReferralEngineService | `app/services/referral_engine.py` | +| ReferralProgram/Code/Attribution | `app/models/referral.py` | + +## Next Phase Entry + +| Field | Value | +| --- | --- | +| Recommended next phase | loyalty-7.6 | +| Blockers | None | +| Entry checklist | Wallet ledger must mirror PointEngine immutability | + +## Completion Sign-Off + +- [x] Tests green (103) +- [x] Docs + snapshot + manifests + +## Related + +- [loyalty-phase-7-5.md](../loyalty-phase-7-5.md) +- [loyalty-phase-7-4.md](../loyalty-phase-7-4.md) diff --git a/docs/phase-handover/phase-7-6.md b/docs/phase-handover/phase-7-6.md new file mode 100644 index 0000000..5504775 --- /dev/null +++ b/docs/phase-handover/phase-7-6.md @@ -0,0 +1,101 @@ +# Phase Handover — Loyalty 7.6 Wallet + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | loyalty-7.6 | +| Title | Wallet | +| Status | Complete | +| Service(s) | loyalty (`loyalty-service`, port 8004) | +| Version | 0.7.6.0 | +| Date | 2026-07-26 | +| ADR(s) | ADR-001, ADR-003, ADR-006, ADR-011 | + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| WalletAccount / WalletLedgerEntry | `app/models/wallet.py` | Immutable ledger pattern (gift cards 7.7) | +| WalletEngineService | `app/services/wallet_engine.py` | Credit/debit/adjust/transfer | +| Wallet validators | `app/validators/wallet.py` | Open-account + currency checks | + +## Public APIs + +| Method | Path | Auth / Permission | Notes | +| --- | --- | --- | --- | +| POST | `/api/v1/wallets` | `loyalty.wallets.create` | Open account | +| GET | `/api/v1/wallets/{id}/balance` | `loyalty.wallets.view` | Computed balance | +| GET | `/api/v1/wallets/{id}/ledger` | `loyalty.wallets.view` | Paginated entries | +| POST | `/api/v1/wallets/{id}/credit` | `loyalty.wallets.credit` | Idempotent optional | +| POST | `/api/v1/wallets/{id}/debit` | `loyalty.wallets.debit` | Rejects insufficient | +| POST | `/api/v1/wallets/{id}/adjust` | `loyalty.wallets.adjust` | Signed delta | +| POST | `/api/v1/wallets/{id}/transfer` | `loyalty.wallets.transfer` | Two-leg atomic | +| POST | `/api/v1/wallets/{id}/delete` | `loyalty.wallets.delete` | Soft close | + +## Events + +| Event type | Domain / Integration | Version | +| --- | --- | --- | +| `loyalty.wallet.opened` | Domain | 1 | +| `loyalty.wallet.credited` | Domain | 1 | +| `loyalty.wallet.debited` | Domain | 1 | +| `loyalty.wallet.adjusted` | Domain | 1 | +| `loyalty.wallet.transferred` | Domain | 1 | +| `loyalty.wallet.closed` | Domain | 1 | + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| Ledger entry types | Add enum + validators + tests | Mutate existing rows | +| Balance source | Always SUM(ledger) | Add mutable balance column | +| Gift cards | Issue via 7.7 on top of wallet or separate ledger | Bypass wallet ledger for stored value without docs | + +## Known Limitations + +- No payment-provider settlement +- Gift cards deferred to 7.7 +- No auto freeze/close scheduler + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | `0007_phase_76_wallet` | +| Upgrade steps | `alembic upgrade head` in loyalty service | +| Downgrade support | Yes | +| Breaking changes | None (additive) | + +## Dependencies + +| Dependency | Type | Required for | +| --- | --- | --- | +| loyalty-7.5 | Phase | Prior engines + programs/members | +| shared-lib | Library | Events, security, exceptions | + +## Next Phase Entry + +1. This handover + [loyalty-phase-7-6.md](../loyalty-phase-7-6.md) +2. Build Gift Card Platform; keep ledger immutability +3. Suggested next: `loyalty-7.7` Gift Card Platform + +| Field | Value | +| --- | --- | +| Recommended next phase | loyalty-7.7 | +| Blockers for next phase | None | +| Entry checklist | Read 7.6 handover; never mutate wallet balances directly | + +## Completion Sign-Off + +- [x] Quality gates passed +- [x] Tests green (114) +- [x] Documentation updated +- [x] Progress / next-steps / registries updated +- [x] Service Snapshot regenerated + +## Related Documents + +- [loyalty-phase-7-6.md](../loyalty-phase-7-6.md) +- [loyalty-phase-7-5.md](../loyalty-phase-7-5.md) +- [ADR-011](../architecture/adr/ADR-011.md) diff --git a/docs/phases/Loyalty/README.md b/docs/phases/Loyalty/README.md index bacf66e..c5383f4 100644 --- a/docs/phases/Loyalty/README.md +++ b/docs/phases/Loyalty/README.md @@ -10,11 +10,11 @@ Not part of CRM. Consumed by Restaurant, Marketplace, Ecommerce, Academy, Bookin | --- | --- | --- | | 7.0 | Foundation | Complete (validated) — [loyalty-phase-7-0.md](../../loyalty-phase-7-0.md) · [audit](../../loyalty-phase-7-0-audit.md) | | 7.1 | Membership Engine | Complete — [loyalty-phase-7-1.md](../../loyalty-phase-7-1.md) · [handover](../../phase-handover/phase-7-1.md) | -| 7.2 | Points Engine (immutable ledger) | Planned | -| 7.3 | Rewards Engine | Planned | -| 7.4 | Campaign Engine | Planned | -| 7.5 | Referral Engine | Planned | -| 7.6 | Wallet | Planned | +| 7.2 | Points Engine (immutable ledger) | Complete — [loyalty-phase-7-2.md](../../loyalty-phase-7-2.md) · [handover](../../phase-handover/phase-7-2.md) | +| 7.3 | Rewards Engine | Complete — [loyalty-phase-7-3.md](../../loyalty-phase-7-3.md) · [handover](../../phase-handover/phase-7-3.md) | +| 7.4 | Campaign Engine | Complete — [loyalty-phase-7-4.md](../../loyalty-phase-7-4.md) · [handover](../../phase-handover/phase-7-4.md) | +| 7.5 | Referral Engine | Complete — [loyalty-phase-7-5.md](../../loyalty-phase-7-5.md) · [handover](../../phase-handover/phase-7-5.md) | +| 7.6 | Wallet | Complete — [loyalty-phase-7-6.md](../../loyalty-phase-7-6.md) · [handover](../../phase-handover/phase-7-6.md) | | 7.7 | Gift Card Platform | Planned | | 7.8 | Analytics | Planned | | 7.9 | Public / Partner / Webhook APIs | Planned | diff --git a/docs/progress.md b/docs/progress.md index 73fd029..b45f3ab 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1,330 +1,930 @@ -# Progress - -> Completed work only. Future → [roadmap.md](roadmap.md). Next milestone → [next-steps.md](next-steps.md). - -## Phase 1 — Core Platform ✅ - -### Infrastructure & structure -- [x] Full project structure with mandatory `backend/` / `frontend/` separation -- [x] Shared library `backend/shared-lib` -- [x] Architecture section: Frontend & Backend Separation - -### Core service -- [x] Config, database, cache, logging, base security -- [x] Models, APIs, Entitlement, Celery, Outbox -- [x] Tests and documentation - -### Frontend -- [x] Independent Next.js, API client, white-label theme baseline - ---- - -## Phase 2 — Identity & Access + SSO ✅ - -- [x] `shared/auth/jwt.py`, `shared/auth/roles.py`, Identity events in `shared/events.py` -- [x] Identity service + `identity_access_db` -- [x] Keycloak realm/clients/theme import -- [x] Core API protection (`AUTH_REQUIRED`, role deps) -- [x] Frontend SSO flow (`/login`, `/auth/callback`, `/dashboard`) -- [x] Compose services Identity + Frontend; Postgres init for identity DB - ---- - -## Phase 3 — OTP Login + Tenant Management ✅ - -- [x] Core `users` + OTP request/verify + Payamak -- [x] Local JWT (HS256) for OTP users -- [x] Admin tenant CRUD + owner filtering -- [x] Frontend admin OTP/tenant UI (later aligned to central SSO) -- [x] `test_otp_auth.py` - ---- - -## Phase 4 — Tenant Onboarding & Workspace Activation ✅ - -> Brief alias: sometimes called “Phase 3” externally — see [phase-numbering decision](decisions/technical/phase-numbering.md). - -- [x] Core `tenant_memberships`, tenant lifecycle, branding columns, domain primary/verification fields -- [x] `users.current_tenant_id`, FREE/STARTER plan seed, onboarding APIs -- [x] `UserService` / `MembershipService` / `OnboardingService` / `TenantContextService` -- [x] Frontend onboarding wizard, tenant dashboard, tenant switcher -- [x] `test_onboarding.py` + regression suite -- [x] JIT Core user from Keycloak JWT (`UserService.resolve_current`) -- [x] Automatic tenant subdomain SSL (Celery → SSH → `provision_ssl.py`) -- [x] Reverse proxy (Nginx) + base TLS + tenant SSL automation - -### Intentionally incomplete after Phase 4 (tracked in roadmap, not claimed done) -Payment gateway · real DNS/TXT custom-domain verify · legacy `POST /admin/tenants` auto membership/plan · first business module - ---- - -## Phase D — Documentation Architecture Consolidation ✅ - -- [x] Target `docs/` tree (architecture, adr, development, reference, deployment, templates, phases, decisions) -- [x] Moved canonical docs with git history where possible -- [x] Deprecated stubs for old paths -- [x] ADR system initialized (ADR-001 … ADR-010) -- [x] Project principles, coding standards, testing/branching/release strategies -- [x] Module registry + provider registry + glossary -- [x] Deployment runbooks (production, SSL, monitoring, backup, restore, DR) -- [x] Roadmap / next-steps separation from progress -- [x] Phase area docs for Accounting, CRM, Restaurant, Marketplace, Automation, AI, Future -- [x] Cross-links updated in root and package READMEs - ---- - -## Phase 5.1–5.11 — Accounting Module ✅ - -- [x] Phases 5.1–5.6: Foundation through Sales Integration (see prior entry) -- [x] Phase 5.7: Purchase & Inventory accounting, InventoryValuationEngine -- [x] Phase 5.8: Fixed Assets, DepreciationEngine, asset lifecycle -- [x] Phase 5.9: HCM/Payroll, PayrollEngine, payroll accounting -- [x] Phase 5.10: Financial Reporting Engine, dashboards, export framework -- [x] Phase 5.11: Compliance, Audit Framework, Governance, SoD -- [x] Alembic migration `0002_phases_57_511` -- [x] 21 tests passing - ---- - -## Accounting Enterprise Frontend (in SuperApp) ✅ baseline - -- [x] Design system tokens (light/dark) + DS components (`components/ds`) -- [x] TanStack Query providers, Sonner toasts, ColorMode -- [x] Typed `accounting-api` client (JWT + `X-Tenant-ID`, no mocks) -- [x] Accounting shell + routes: dashboard, COA, fiscal, currencies, cost centers, projects, ledger, setup, suppliers -- [x] Vouchers list/create/detail with validate/post/reverse/cancel + draft edit (ADR-010) -- [x] Foundation masters: edit/archive drawers, fiscal year/period actions, COA template import wizard -- [x] Setup checklist + completion scoreboard on dashboard -- [x] Treasury, customers, assets, payroll, reports, audit, settings pages (real APIs) -- [x] Catalog tile available → `/accounting` -- [x] Frontend docs under `docs/frontend/` - -Remaining: Phase 5.12 AI UI blocked on AI provider; optional richer PDF export file delivery. - ---- - -## Accounting Frontend Integration (FE ↔ BE 5.1–5.11) ✅ - -- [x] Full route/menu scan vs `/api/v1` routers; gap matrix executed -- [x] Domain rewires: payroll employees, compliance policies/risks, settlements, sales-accounting post, purchase GR + valuation, asset schedule, monitoring health -- [x] FE client: settlements, salesAccounting, purchaseInventory, compliance approvals/risks, ops PATCH -- [x] Ops documents: detail + edit; treasury receipt/payment cash/bank binding; voucher list pagination -- [x] BE list endpoints for settlements, risks, purchase posting-profiles (integration completeness) -- [x] Report: [accounting-frontend-integration-report.md](accounting-frontend-integration-report.md) -- [x] Scoreboard honesty for partial/ops-backed modules; AI remains blocked - ---- - -## Phase 6.0 — CRM Service Foundation ✅ - -- [x] `backend/services/crm` service scaffold (API / services / repositories / models) -- [x] Base aggregates: Lead, Contact, Organization, Opportunity, Pipeline, PipelineStage, SalesActivity, Quote -- [x] Publish-only event contracts (`crm.lead.*`, `crm.opportunity.*`, …) -- [x] CRM APIs only (`/api/v1/leads|contacts|organizations|pipelines|opportunities|activities|quotes`) -- [x] Platform provider contracts only (Automation, Customer360, Notification, Analytics, AI, Communication, Helpdesk) -- [x] Permissions `crm.*` trees; tenant isolation via `X-Tenant-ID` -- [x] Alembic `0001_initial` + `crm_db` wiring (compose port 8003) -- [x] Architecture / dependency / repository / migration / API / permission / tenant / docs tests -- [x] Docs: [crm-phase-6-0.md](crm-phase-6-0.md), module registry, phase area README - ---- - -## Phase 6.1 — Enterprise CRM Core Business Entities ✅ - -- [x] Expanded Lead / Contact / Organization (numbers, multi-contact, soft delete, audit actors) -- [x] Lookups: lead_sources, lead_statuses, contact_types, organization_types, organization_industries -- [x] Lead assignments, tag engine, reusable addresses, custom fields + values -- [x] Versioned notes, attachment file-storage references (no binary), CRM audit log -- [x] Services / repositories / validators / permissions / events for Phase 6.1 -- [x] Alembic `0002_phase_61_entities` -- [x] API coverage under `/api/v1` for entities + lookups -- [x] Tests: model, repository, service, API, permission, migration, validation, audit, tenant, docs -- [x] Docs: [crm-phase-6-1.md](crm-phase-6-1.md) - ---- - -## Phase 6.2 — Enterprise Sales Process Engine ✅ - -- [x] Opportunity engine (number, title, revenue, probability, priority, close dates, soft delete, actors) -- [x] Unlimited pipelines + pipeline stages (start/won/lost/closed flags, required fields, exit validation) -- [x] Stage history, opportunity products (product_ref only), contact roles, competitors -- [x] Sales playbooks (versioned) + steps + assignments + completion tracking -- [x] Win/loss categories, reasons, immutable opportunity_win_loss snapshot -- [x] Rule-based forecasts (pipeline / weighted / committed; monthly/quarterly/annual) -- [x] Sales goals + targets with achievement % -- [x] Sales process definitions + sales_audit trail -- [x] Services / validators / events / permissions (`crm.pipelines|playbooks|forecasts|goals|targets.*`) -- [x] Alembic `0003_phase_62_sales_engine`; version `0.6.2.0` -- [x] API coverage for sales engine under `/api/v1` -- [x] Tests: pipeline, stage, playbook, forecast, win/loss, business rules, migration, permission, tenant, docs -- [x] Docs: [crm-phase-6-2.md](crm-phase-6-2.md) - ---- - -## Phase 6.3 — Enterprise Sales Collaboration Platform ✅ - -- [x] Expanded sales activities (types, priority, CRM entity links, participants, reminders) -- [x] Tasks + checklists + completion tracking -- [x] Meetings + attendees + finish/outcome -- [x] Call logs (opportunity-linked) + call outcomes -- [x] Email threads/messages metadata only (no delivery) -- [x] Immutable sales timeline + timeline event links -- [x] Threaded/versioned comments, mentions, bookmarks, favorites -- [x] Sales team roles/members/assignments + notification preferences -- [x] Collaboration audit; events `crm.task|meeting|call|timeline|comment|mention.*` -- [x] Permissions `crm.tasks|meetings|calls|timeline|comments|mentions|team.*` -- [x] Alembic `0004_phase_63_collaboration`; version `0.6.3.0` -- [x] Tests + docs: [crm-phase-6-3.md](crm-phase-6-3.md) - -### CRM Core Platform - -- [x] Phases 6.0–6.3 complete (Foundation, Master Data, Sales Engine, Collaboration) - ---- - -## Phase 7.0 — Loyalty Service Foundation ✅ - -- [x] `backend/services/loyalty` service scaffold (API / services / repositories / models) -- [x] Foundation aggregates: LoyaltyProgram, MembershipTier, Member, PointAccount, Reward, Campaign, LoyaltyAuditLog, OutboxEvent -- [x] No mutable balance on PointAccount — direct balance mutation forbidden (`extra=forbid`) -- [x] Optimistic locking on Program / Member / PointAccount; campaign `rule_version` -- [x] Publish-only events via transactional outbox (`loyalty.program|tier|member|point_account|reward|campaign.*`) -- [x] Loyalty APIs only (`/api/v1/programs|tiers|members|point-accounts|rewards|campaigns|audit`) -- [x] Platform provider contracts only (Notification, Analytics, Customer360, AI, ModuleIntegration, CRM, Communication, FileStorage) -- [x] Permissions `loyalty.*` trees with manage/view inheritance; tenant isolation via `X-Tenant-ID` -- [x] Alembic `0001_initial` + `loyalty_db` wiring (compose port 8004) -- [x] Soft-delete unique reclaim; IntegrityError → 409; null required-field → 422; production config guards -- [x] Enterprise validation & self-heal (incl. removal of incomplete 7.1 residue) — **52 tests passed** -- [x] Docs: [loyalty-phase-7-0.md](loyalty-phase-7-0.md), [loyalty-phase-7-0-audit.md](loyalty-phase-7-0-audit.md), ADR-011, module registry, phase area README - -## Phase 7.1 — Membership Engine ✅ - -- [x] Lifecycle state machine (activate / renew / freeze / resume / cancel / expire / transfer) -- [x] Member lifecycle fields + `MembershipLifecycleEvent` append-only history -- [x] Program soft-delete cascade policy: reject when blocking members exist -- [x] Permissions `loyalty.members.{activate,renew,freeze,resume,cancel,expire,transfer,lifecycle.view}` -- [x] Events `loyalty.member.{activated,renewed,frozen,resumed,cancelled,expired,transferred}` -- [x] Alembic `0002_phase_71_membership`; version `0.7.1.0` -- [x] Tests green (59) including `test_phase71.py` -- [x] Docs: [loyalty-phase-7-1.md](loyalty-phase-7-1.md), [phase-handover/phase-7-1.md](phase-handover/phase-7-1.md), manifests, registry - ---- - -## Phase 8.0–8.10 — Enterprise Communication Platform ✅ - -- [x] Independent `communication-service` / `communication_db` (port 8005) — not owned by CRM/Loyalty/Restaurant -- [x] Provider framework + registry (mock, payamak; stubs for email/push/whatsapp/telegram/rubika/voice) -- [x] Communication router with priority, automatic failover, retry, circuit breaker -- [x] SMS platform: sender numbers, balance, rate limits, provider logs -- [x] Template engine: variables, locale, versioning, approval, preview, rendering -- [x] Dynamic contact sources (manual, CSV, CRM/Loyalty/Restaurant/Marketplace/Accounting/Website/External REST) — resolve only, no duplication -- [x] Queue engine: async queue, scheduling, priority, backoff retry, DLQ, batch -- [x] Delivery tracking: queued/sent/delivered/failed/expired/cancelled + timeline -- [x] OTP platform: generate/verify/expire, rate limit, brute-force lock, multi-channel path -- [x] Incoming provider webhooks with signature verification -- [x] Health `/health`, Capability `/capabilities`, providers status, monitoring stats -- [x] Permissions `communication.*`; events `communication.*`; audit on every send -- [x] Alembic `0001_initial`; version `0.8.10.0` -- [x] Tests: architecture, health/security, providers/SMS/failover/queue, templates/contacts/OTP/webhooks, validators -- [x] Docs: [communication-phase-8.md](communication-phase-8.md), ADR-012, module/provider registry, progress - ---- - -## Phase AF — AI Development Framework ✅ - -> Documentation-only. No business functionality. No modifications to existing services, schemas, or APIs. - -- [x] Permanent area [`docs/ai-framework/`](ai-framework/README.md) -- [x] Master prompt, development loop, phase handover, quality gates -- [x] Templates: phase, service, module, entity, repository, service-layer, API, event, testing, documentation -- [x] Manifests: [phase-manifest.yaml](ai-framework/phase-manifest.yaml), [service-manifest.yaml](ai-framework/service-manifest.yaml) -- [x] Prompt rules + Cursor guidelines -- [x] Docs index / module registry / progress integration -- [x] ADR-013 Accepted -- [x] Architecture, documentation, cross-reference, link, template, and manifest validation - ---- - -## Phase SC-Reg — Sports Center Platform Registration ✅ - -> Documentation-only registration. No business code, models, APIs, migrations, or repositories. - -- [x] Phase roadmap 9.0–9.10 registered in [phase-manifest.yaml](ai-framework/phase-manifest.yaml) -- [x] Service registered in [service-manifest.yaml](ai-framework/service-manifest.yaml) -- [x] Planned modules registered in [module-registry.md](module-registry.md#sports_center) -- [x] Sports terminology added to [glossary.md](glossary.md#sports-center) -- [x] Roadmap doc: [sports-center-roadmap.md](sports-center-roadmap.md) -- [x] ADR-014 Accepted — Independent Sports Center Platform -- [x] Module boundaries + phase area README updated -- [x] Manifest / cross-reference / documentation / module validation passed - ---- - -## Phase 9.0 — Sports Center Platform Foundation ✅ - -- [x] `backend/services/sports_center` service scaffold (API / services / repositories / models) -- [x] Foundation aggregates: SportsCenter, Branch, Sport, MembershipType, Membership, Coach, SportsRole, SportsPermission, Facility, Court, Room, LockerRoom, Locker, DeviceProvider, Device, AttendanceGateway, SportsConfiguration, SportsEvent, SportsSetting, SportsAuditLog -- [x] Adapter-based connector framework (QR/RFID/Barcode/Fingerprint/Face/Turnstile/Door/Payment/Attendance) — interfaces only -- [x] Platform provider contracts only (Accounting, CRM, Loyalty, Communication, Notification, Storage, AI, Identity, Customer360) -- [x] Publish-only events (`sports_center.member|membership|coach|facility|device|attendance|locker.*`) -- [x] Sports APIs under `/api/v1` + device connect/disconnect + locker assign/release -- [x] Permissions `sports_center.*` trees; tenant isolation via `X-Tenant-ID` -- [x] Alembic `0001_initial` + `sports_center_db` wiring (compose port 8006) -- [x] Architecture / API / permissions / migration / dependency / connectors / docs tests -- [x] Docs: [sports-center-phase-9-0.md](sports-center-phase-9-0.md), [phase-handover/phase-9-0.md](phase-handover/phase-9-0.md), ADR-014, module registry -- [x] No business workflow engines (booking/attendance lifecycle deferred) - ---- - -## Phase 9.1 — Sports Center Membership Catalog ✅ - -- [x] Catalog aggregates: SportCategory, AgeGroup, PricingModel, MembershipPackage, MembershipPlan, MembershipRule, RenewalPolicy, FreezingRule, ExpirationPolicy -- [x] MembershipType extended with catalog refs (package/plan/pricing/age/category/policies), transferability, sort order -- [x] Validators for age range, pricing/billing/rule kinds, money amounts, catalog ref integrity -- [x] Publish-only catalog events; permissions trees for catalog modules -- [x] APIs under `/api/v1/sport-categories|age-groups|pricing-models|membership-packages|membership-plans|membership-rules|renewal-policies|freezing-rules|expiration-policies` -- [x] `/capabilities` endpoint; version `0.9.1.0` -- [x] Alembic `0002_phase_91_membership_catalog` -- [x] Tests: catalog API, tenant isolation, age validation, architecture, docs -- [x] Docs: [sports-center-phase-9-1.md](sports-center-phase-9-1.md), [phase-handover/phase-9-1.md](phase-handover/phase-9-1.md) -- [x] No member enrollment / coach / attendance / booking engines - ---- - -## Phase 9.2 — Sports Center Member Management ✅ - -- [x] Members aggregate + family members, emergency contacts, medical information shells -- [x] Membership assignment (`member_id`) consuming Phase 9.1 catalog types -- [x] Status management: activate / suspend / cancel / freeze / unfreeze -- [x] Membership cards (QR/physical/digital), digital membership passes, waivers (sign), member documents (Storage refs) -- [x] Validators, permissions (`sports_center.members.*` trees), publish-only events -- [x] Alembic `0003_phase_92_member_management`; version `0.9.2.0` -- [x] Tests: members API, tenant isolation, transitions, architecture, migration, docs -- [x] Docs: [sports-center-phase-9-2.md](sports-center-phase-9-2.md), [phase-handover/phase-9-2.md](phase-handover/phase-9-2.md) -- [x] No coach / attendance / booking engines - ---- - -## Related Documents - -- [Next Steps](next-steps.md) -- [Roadmap](roadmap.md) -- [Frontend docs](frontend/README.md) -- [docs/README.md](README.md) -- [CRM Phase 6.0](crm-phase-6-0.md) -- [CRM Phase 6.1](crm-phase-6-1.md) -- [CRM Phase 6.2](crm-phase-6-2.md) -- [CRM Phase 6.3](crm-phase-6-3.md) -- [Loyalty Phase 7.0](loyalty-phase-7-0.md) -- [Loyalty Phase 7.1](loyalty-phase-7-1.md) -- [Communication Phase 8](communication-phase-8.md) -- [Sports Center Phase 9.0](sports-center-phase-9-0.md) -- [Sports Center Phase 9.1](sports-center-phase-9-1.md) -- [Sports Center Phase 9.2](sports-center-phase-9-2.md) -- [Phase Handover 9.0](phase-handover/phase-9-0.md) -- [Phase Handover 9.1](phase-handover/phase-9-1.md) -- [Phase Handover 9.2](phase-handover/phase-9-2.md) -- [Sports Center Roadmap](sports-center-roadmap.md) -- [AI Development Framework](ai-framework/README.md) -- [ADR-013](architecture/adr/ADR-013.md) -- [ADR-014](architecture/adr/ADR-014.md) +# Progress + +> Completed work only. Future → [roadmap.md](roadmap.md). Next milestone → [next-steps.md](next-steps.md). + +## Phase 1 — Core Platform ✅ + +### Infrastructure & structure +- [x] Full project structure with mandatory `backend/` / `frontend/` separation +- [x] Shared library `backend/shared-lib` +- [x] Architecture section: Frontend & Backend Separation + +### Core service +- [x] Config, database, cache, logging, base security +- [x] Models, APIs, Entitlement, Celery, Outbox +- [x] Tests and documentation + +### Frontend +- [x] Independent Next.js, API client, white-label theme baseline + +--- + +## Phase 2 — Identity & Access + SSO ✅ + +- [x] `shared/auth/jwt.py`, `shared/auth/roles.py`, Identity events in `shared/events.py` +- [x] Identity service + `identity_access_db` +- [x] Keycloak realm/clients/theme import +- [x] Core API protection (`AUTH_REQUIRED`, role deps) +- [x] Frontend SSO flow (`/login`, `/auth/callback`, `/dashboard`) +- [x] Compose services Identity + Frontend; Postgres init for identity DB + +--- + +## Phase 3 — OTP Login + Tenant Management ✅ + +- [x] Core `users` + OTP request/verify + Payamak +- [x] Local JWT (HS256) for OTP users +- [x] Admin tenant CRUD + owner filtering +- [x] Frontend admin OTP/tenant UI (later aligned to central SSO) +- [x] `test_otp_auth.py` + +--- + +## Phase 4 — Tenant Onboarding & Workspace Activation ✅ + +> Brief alias: sometimes called “Phase 3” externally — see [phase-numbering decision](decisions/technical/phase-numbering.md). + +- [x] Core `tenant_memberships`, tenant lifecycle, branding columns, domain primary/verification fields +- [x] `users.current_tenant_id`, FREE/STARTER plan seed, onboarding APIs +- [x] `UserService` / `MembershipService` / `OnboardingService` / `TenantContextService` +- [x] Frontend onboarding wizard, tenant dashboard, tenant switcher +- [x] `test_onboarding.py` + regression suite +- [x] JIT Core user from Keycloak JWT (`UserService.resolve_current`) +- [x] Automatic tenant subdomain SSL (Celery → SSH → `provision_ssl.py`) +- [x] Reverse proxy (Nginx) + base TLS + tenant SSL automation + +### Intentionally incomplete after Phase 4 (tracked in roadmap, not claimed done) +Payment gateway · real DNS/TXT custom-domain verify · legacy `POST /admin/tenants` auto membership/plan · first business module + +--- + +## Phase D — Documentation Architecture Consolidation ✅ + +- [x] Target `docs/` tree (architecture, adr, development, reference, deployment, templates, phases, decisions) +- [x] Moved canonical docs with git history where possible +- [x] Deprecated stubs for old paths +- [x] ADR system initialized (ADR-001 … ADR-010) +- [x] Project principles, coding standards, testing/branching/release strategies +- [x] Module registry + provider registry + glossary +- [x] Deployment runbooks (production, SSL, monitoring, backup, restore, DR) +- [x] Roadmap / next-steps separation from progress +- [x] Phase area docs for Accounting, CRM, Restaurant, Marketplace, Automation, AI, Future +- [x] Cross-links updated in root and package READMEs + +--- + +## Phase 5.1–5.11 — Accounting Module ✅ + +- [x] Phases 5.1–5.6: Foundation through Sales Integration (see prior entry) +- [x] Phase 5.7: Purchase & Inventory accounting, InventoryValuationEngine +- [x] Phase 5.8: Fixed Assets, DepreciationEngine, asset lifecycle +- [x] Phase 5.9: HCM/Payroll, PayrollEngine, payroll accounting +- [x] Phase 5.10: Financial Reporting Engine, dashboards, export framework +- [x] Phase 5.11: Compliance, Audit Framework, Governance, SoD +- [x] Alembic migration `0002_phases_57_511` +- [x] 21 tests passing + +--- + +## Accounting Enterprise Frontend (in SuperApp) ✅ baseline + +- [x] Design system tokens (light/dark) + DS components (`components/ds`) +- [x] TanStack Query providers, Sonner toasts, ColorMode +- [x] Typed `accounting-api` client (JWT + `X-Tenant-ID`, no mocks) +- [x] Accounting shell + routes: dashboard, COA, fiscal, currencies, cost centers, projects, ledger, setup, suppliers +- [x] Vouchers list/create/detail with validate/post/reverse/cancel + draft edit (ADR-010) +- [x] Foundation masters: edit/archive drawers, fiscal year/period actions, COA template import wizard +- [x] Setup checklist + completion scoreboard on dashboard +- [x] Treasury, customers, assets, payroll, reports, audit, settings pages (real APIs) +- [x] Catalog tile available → `/accounting` +- [x] Frontend docs under `docs/frontend/` + +Remaining: Phase 5.12 AI UI blocked on AI provider; optional richer PDF export file delivery. + +--- + +## Accounting Frontend Integration (FE ↔ BE 5.1–5.11) ✅ + +- [x] Full route/menu scan vs `/api/v1` routers; gap matrix executed +- [x] Domain rewires: payroll employees, compliance policies/risks, settlements, sales-accounting post, purchase GR + valuation, asset schedule, monitoring health +- [x] FE client: settlements, salesAccounting, purchaseInventory, compliance approvals/risks, ops PATCH +- [x] Ops documents: detail + edit; treasury receipt/payment cash/bank binding; voucher list pagination +- [x] BE list endpoints for settlements, risks, purchase posting-profiles (integration completeness) +- [x] Report: [accounting-frontend-integration-report.md](accounting-frontend-integration-report.md) +- [x] Scoreboard honesty for partial/ops-backed modules; AI remains blocked + +--- + +## Accounting Frontend Completion Loop (business workflows) ✅ + +- [x] Full sidebar/submenu audit (118 routes); generic BusinessDocs stubs replaced +- [x] Thin BE APIs on existing models: asset transfer/dispose/revaluation; payroll contracts/components/payrolls; compliance workflows/approvals list/SoD/controls/violations +- [x] Domain screens: `WorkflowScreens.tsx` + typed `SpecializedOpsPage` for budget/integration/documents/settings +- [x] Money thousand-separators + party/item AJAX across operational forms +- [x] Audit report: [audits/accounting-frontend-completion-report.md](audits/accounting-frontend-completion-report.md) +- [x] Menu QA tracker: [frontend/accounting-menu-qa.md](frontend/accounting-menu-qa.md) + +Remaining (honest): Phase 5.12 AI blocked; budget/DMS/integration still typed-ops interim until dedicated engines exist. + +--- + +## Phase 6.0 — CRM Service Foundation ✅ + +- [x] `backend/services/crm` service scaffold (API / services / repositories / models) +- [x] Base aggregates: Lead, Contact, Organization, Opportunity, Pipeline, PipelineStage, SalesActivity, Quote +- [x] Publish-only event contracts (`crm.lead.*`, `crm.opportunity.*`, …) +- [x] CRM APIs only (`/api/v1/leads|contacts|organizations|pipelines|opportunities|activities|quotes`) +- [x] Platform provider contracts only (Automation, Customer360, Notification, Analytics, AI, Communication, Helpdesk) +- [x] Permissions `crm.*` trees; tenant isolation via `X-Tenant-ID` +- [x] Alembic `0001_initial` + `crm_db` wiring (compose port 8003) +- [x] Architecture / dependency / repository / migration / API / permission / tenant / docs tests +- [x] Docs: [crm-phase-6-0.md](crm-phase-6-0.md), module registry, phase area README + +--- + +## Phase 6.1 — Enterprise CRM Core Business Entities ✅ + +- [x] Expanded Lead / Contact / Organization (numbers, multi-contact, soft delete, audit actors) +- [x] Lookups: lead_sources, lead_statuses, contact_types, organization_types, organization_industries +- [x] Lead assignments, tag engine, reusable addresses, custom fields + values +- [x] Versioned notes, attachment file-storage references (no binary), CRM audit log +- [x] Services / repositories / validators / permissions / events for Phase 6.1 +- [x] Alembic `0002_phase_61_entities` +- [x] API coverage under `/api/v1` for entities + lookups +- [x] Tests: model, repository, service, API, permission, migration, validation, audit, tenant, docs +- [x] Docs: [crm-phase-6-1.md](crm-phase-6-1.md) + +--- + +## Phase 6.2 — Enterprise Sales Process Engine ✅ + +- [x] Opportunity engine (number, title, revenue, probability, priority, close dates, soft delete, actors) +- [x] Unlimited pipelines + pipeline stages (start/won/lost/closed flags, required fields, exit validation) +- [x] Stage history, opportunity products (product_ref only), contact roles, competitors +- [x] Sales playbooks (versioned) + steps + assignments + completion tracking +- [x] Win/loss categories, reasons, immutable opportunity_win_loss snapshot +- [x] Rule-based forecasts (pipeline / weighted / committed; monthly/quarterly/annual) +- [x] Sales goals + targets with achievement % +- [x] Sales process definitions + sales_audit trail +- [x] Services / validators / events / permissions (`crm.pipelines|playbooks|forecasts|goals|targets.*`) +- [x] Alembic `0003_phase_62_sales_engine`; version `0.6.2.0` +- [x] API coverage for sales engine under `/api/v1` +- [x] Tests: pipeline, stage, playbook, forecast, win/loss, business rules, migration, permission, tenant, docs +- [x] Docs: [crm-phase-6-2.md](crm-phase-6-2.md) + +--- + +## Phase 6.3 — Enterprise Sales Collaboration Platform ✅ + +- [x] Expanded sales activities (types, priority, CRM entity links, participants, reminders) +- [x] Tasks + checklists + completion tracking +- [x] Meetings + attendees + finish/outcome +- [x] Call logs (opportunity-linked) + call outcomes +- [x] Email threads/messages metadata only (no delivery) +- [x] Immutable sales timeline + timeline event links +- [x] Threaded/versioned comments, mentions, bookmarks, favorites +- [x] Sales team roles/members/assignments + notification preferences +- [x] Collaboration audit; events `crm.task|meeting|call|timeline|comment|mention.*` +- [x] Permissions `crm.tasks|meetings|calls|timeline|comments|mentions|team.*` +- [x] Alembic `0004_phase_63_collaboration`; version `0.6.3.0` +- [x] Tests + docs: [crm-phase-6-3.md](crm-phase-6-3.md) + +### CRM Core Platform + +- [x] Phases 6.0–6.3 complete (Foundation, Master Data, Sales Engine, Collaboration) + +--- + +## Phase 7.0 — Loyalty Service Foundation ✅ + +- [x] `backend/services/loyalty` service scaffold (API / services / repositories / models) +- [x] Foundation aggregates: LoyaltyProgram, MembershipTier, Member, PointAccount, Reward, Campaign, LoyaltyAuditLog, OutboxEvent +- [x] No mutable balance on PointAccount — direct balance mutation forbidden (`extra=forbid`) +- [x] Optimistic locking on Program / Member / PointAccount; campaign `rule_version` +- [x] Publish-only events via transactional outbox (`loyalty.program|tier|member|point_account|reward|campaign.*`) +- [x] Loyalty APIs only (`/api/v1/programs|tiers|members|point-accounts|rewards|campaigns|audit`) +- [x] Platform provider contracts only (Notification, Analytics, Customer360, AI, ModuleIntegration, CRM, Communication, FileStorage) +- [x] Permissions `loyalty.*` trees with manage/view inheritance; tenant isolation via `X-Tenant-ID` +- [x] Alembic `0001_initial` + `loyalty_db` wiring (compose port 8004) +- [x] Soft-delete unique reclaim; IntegrityError → 409; null required-field → 422; production config guards +- [x] Enterprise validation & self-heal (incl. removal of incomplete 7.1 residue) — **52 tests passed** +- [x] Docs: [loyalty-phase-7-0.md](loyalty-phase-7-0.md), [loyalty-phase-7-0-audit.md](loyalty-phase-7-0-audit.md), ADR-011, module registry, phase area README + +## Phase 7.1 — Membership Engine ✅ + +- [x] Lifecycle state machine (activate / renew / freeze / resume / cancel / expire / transfer) +- [x] Member lifecycle fields + `MembershipLifecycleEvent` append-only history +- [x] Program soft-delete cascade policy: reject when blocking members exist +- [x] Permissions `loyalty.members.{activate,renew,freeze,resume,cancel,expire,transfer,lifecycle.view}` +- [x] Events `loyalty.member.{activated,renewed,frozen,resumed,cancelled,expired,transferred}` +- [x] Alembic `0002_phase_71_membership`; version `0.7.1.0` +- [x] Tests green (59) including `test_phase71.py` +- [x] Docs: [loyalty-phase-7-1.md](loyalty-phase-7-1.md), [phase-handover/phase-7-1.md](phase-handover/phase-7-1.md), manifests, registry + +## Phase 7.2 — Point Engine ✅ + +- [x] Immutable `PointLedgerEntry` append-only ledger; balance = SUM(signed amounts) +- [x] No mutable balance on `PointAccount` +- [x] Earn / redeem / adjust / expire APIs with idempotency + insufficient-balance checks +- [x] Permissions `loyalty.points.{view,earn,redeem,adjust,expire,manage}` +- [x] Events `loyalty.points.{earned,redeemed,adjusted,expired}` +- [x] Alembic `0003_phase_72_points`; version `0.7.2.0` +- [x] Tests green (61) including `test_phase72.py` +- [x] Docs: [loyalty-phase-7-2.md](loyalty-phase-7-2.md), [phase-handover/phase-7-2.md](phase-handover/phase-7-2.md), [service-snapshots/loyalty.yaml](service-snapshots/loyalty.yaml), manifests, registry + +## Phase 7.3 — Rewards Engine ✅ + +- [x] Reward inventory fields + `RewardRedemption` lifecycle (pending/fulfilled/cancelled) +- [x] Redeem / fulfill / cancel; points via PointEngine only (`auto_commit` composition) +- [x] Permissions `loyalty.rewards.{redeem,fulfill,cancel}`, `loyalty.redemptions.view` +- [x] Events `loyalty.reward.{redeemed,fulfilled,redemption_cancelled}` +- [x] Alembic `0004_phase_73_rewards`; version `0.7.3.0` +- [x] Tests green (72) including `test_phase73.py` +- [x] Docs: [loyalty-phase-7-3.md](loyalty-phase-7-3.md), [phase-handover/phase-7-3.md](phase-handover/phase-7-3.md), snapshot, manifests + +## Phase 7.4 — Campaign Engine ✅ + +- [x] Campaign lifecycle + versioned rules + apply grants via PointEngine +- [x] `CampaignApplication` tracking; Alembic `0005_phase_74_campaigns`; version `0.7.4.0` +- [x] Tests green; docs [loyalty-phase-7-4.md](loyalty-phase-7-4.md), [phase-handover/phase-7-4.md](phase-handover/phase-7-4.md) + +## Phase 7.5 — Referral Engine ✅ + +- [x] ReferralProgram / ReferralCode / ReferralAttribution lifecycle +- [x] Dual-sided bonuses via PointEngine; Alembic `0006_phase_75_referral`; version `0.7.5.0` +- [x] Tests green (103); docs [loyalty-phase-7-5.md](loyalty-phase-7-5.md), [phase-handover/phase-7-5.md](phase-handover/phase-7-5.md) + +## Phase 7.6 — Wallet ✅ + +- [x] `WalletAccount` shell + immutable `WalletLedgerEntry` (no mutable balance) +- [x] Credit / debit / adjust / transfer APIs with idempotency + insufficient-funds checks +- [x] Permissions `loyalty.wallets.{view,create,credit,debit,adjust,transfer,delete,manage}` +- [x] Events `loyalty.wallet.{opened,credited,debited,adjusted,transferred,closed}` +- [x] Alembic `0007_phase_76_wallet`; version `0.7.6.0` +- [x] Tests green (114) including `test_phase76.py` +- [x] Docs: [loyalty-phase-7-6.md](loyalty-phase-7-6.md), [phase-handover/phase-7-6.md](phase-handover/phase-7-6.md), snapshot, manifests + +--- + +## Phase 8.0–8.10 — Enterprise Communication Platform ✅ (Production Ready — SMS MVP) + +- [x] Independent `communication-service` / `communication_db` (port 8005) — not owned by CRM/Loyalty/Restaurant +- [x] Provider framework + registry (mock, payamak; stubs for email/push/whatsapp/telegram/rubika/voice) +- [x] Communication router with priority, automatic failover, retry, circuit breaker +- [x] SMS platform: sender numbers, balance, rate limits, provider logs +- [x] Template engine: variables, locale, versioning, approval, preview, rendering +- [x] Dynamic contact sources (manual, CSV, CRM/Loyalty/Restaurant/Marketplace/Accounting/Website/External REST) — resolve only, no duplication +- [x] Queue engine: async queue, scheduling, priority, backoff retry, DLQ, batch +- [x] Delivery tracking: queued/sent/delivered/failed/expired/cancelled + timeline +- [x] OTP platform: generate/verify/expire, rate limit, brute-force lock, multi-channel path +- [x] Incoming provider webhooks with signature verification +- [x] Health `/health`, Capability `/capabilities`, providers status, monitoring stats +- [x] Permissions `communication.*`; events `communication.*`; audit on every send +- [x] Alembic `0001_initial`, `0002_validation_hardening`; version `0.8.10.1` +- [x] Tests: architecture, health/security, providers/SMS/failover/queue, templates/contacts/OTP/webhooks, validators (42 passing) +- [x] **Production Ready (SMS MVP)** — feature-complete for current scope; future channels in [communication-roadmap.md](communication-roadmap.md) only +- [x] Docs: [communication-phase-8.md](communication-phase-8.md), [phase-handover/phase-8.md](phase-handover/phase-8.md), [service-snapshots/communication.yaml](service-snapshots/communication.yaml), ADR-012, module/provider registry, progress + +--- + +## Phase AF — AI Development Framework ✅ + +> Documentation-only. No business functionality. No modifications to existing services, schemas, or APIs. + +- [x] Permanent area [`docs/ai-framework/`](ai-framework/README.md) +- [x] Master prompt, development loop, phase handover, quality gates +- [x] Templates: phase, service, module, entity, repository, service-layer, API, event, testing, documentation +- [x] Manifests: [phase-manifest.yaml](ai-framework/phase-manifest.yaml), [service-manifest.yaml](ai-framework/service-manifest.yaml) +- [x] Prompt rules + Cursor guidelines +- [x] Docs index / module registry / progress integration +- [x] ADR-013 Accepted +- [x] Architecture, documentation, cross-reference, link, template, and manifest validation + +--- + +## Phase AF-Enterprise — Enterprise Development Framework Upgrade ✅ + +> Documentation-only. Upgrades the AI Development Framework into an Enterprise Development Framework. No business feature code. No changes to completed business phases or implementations. + +- [x] [Definition of Done](ai-framework/definition-of-done.md) +- [x] [Mandatory Phase Artifacts](ai-framework/mandatory-phase-artifacts.md) +- [x] [Enterprise Completeness](ai-framework/enterprise-completeness.md) +- [x] [Boundary Rules](ai-framework/boundary-rules.md) +- [x] Master prompt, development loop, quality gates, phase/handover/testing/documentation templates upgraded +- [x] Layer templates (service, module, entity, repository, service-layer, API, event) upgraded +- [x] Prompt rules + Cursor guidelines upgraded +- [x] Project principles, testing strategy, docs index, glossary updated +- [x] [ADR-018](architecture/adr/ADR-018.md) Accepted (extends ADR-013) +- [x] Phase registered: `ai-framework-enterprise` in [phase-manifest.yaml](ai-framework/phase-manifest.yaml) +- [x] Handover: [phase-handover/phase-af-enterprise.md](phase-handover/phase-af-enterprise.md) +- [x] Backward-compatible path `docs/ai-framework/` retained; no business services modified + +--- + +## Phase AF-Discovery — Enterprise Phase Discovery Mandate ✅ + +> Documentation-only. Adds mandatory Enterprise Phase Discovery before every phase. No business feature code. No changes to completed business phases or implementations. + +- [x] [Enterprise Phase Discovery](ai-framework/enterprise-phase-discovery.md) +- [x] Development loop stage 1 = Discovery; quality gates include Discovery +- [x] Phase template, handover, DoD, completeness, mandatory artifacts, prompt/cursor rules updated +- [x] Master prompt + project principles updated +- [x] [ADR-019](architecture/adr/ADR-019.md) Accepted (extends ADR-018) +- [x] Phase registered: `ai-framework-discovery` in [phase-manifest.yaml](ai-framework/phase-manifest.yaml) +- [x] Handover: [phase-handover/phase-af-discovery.md](phase-handover/phase-af-discovery.md) +- [x] No business services modified + +--- + +## Phase SC-Reg — Sports Center Platform Registration ✅ + +> Documentation-only registration. No business code, models, APIs, migrations, or repositories. + +- [x] Phase roadmap 9.0–9.10 registered in [phase-manifest.yaml](ai-framework/phase-manifest.yaml) +- [x] Service registered in [service-manifest.yaml](ai-framework/service-manifest.yaml) +- [x] Planned modules registered in [module-registry.md](module-registry.md#sports_center) +- [x] Sports terminology added to [glossary.md](glossary.md#sports-center) +- [x] Roadmap doc: [sports-center-roadmap.md](sports-center-roadmap.md) +- [x] ADR-014 Accepted — Independent Sports Center Platform +- [x] Module boundaries + phase area README updated +- [x] Manifest / cross-reference / documentation / module validation passed + +--- + +## Phase 9.0 — Sports Center Platform Foundation ✅ + +- [x] `backend/services/sports_center` service scaffold (API / services / repositories / models) +- [x] Foundation aggregates: SportsCenter, Branch, Sport, MembershipType, Membership, Coach, SportsRole, SportsPermission, Facility, Court, Room, LockerRoom, Locker, DeviceProvider, Device, AttendanceGateway, SportsConfiguration, SportsEvent, SportsSetting, SportsAuditLog +- [x] Adapter-based connector framework (QR/RFID/Barcode/Fingerprint/Face/Turnstile/Door/Payment/Attendance) — interfaces only +- [x] Platform provider contracts only (Accounting, CRM, Loyalty, Communication, Notification, Storage, AI, Identity, Customer360) +- [x] Publish-only events (`sports_center.member|membership|coach|facility|device|attendance|locker.*`) +- [x] Sports APIs under `/api/v1` + device connect/disconnect + locker assign/release +- [x] Permissions `sports_center.*` trees; tenant isolation via `X-Tenant-ID` +- [x] Alembic `0001_initial` + `sports_center_db` wiring (compose port 8006) +- [x] Architecture / API / permissions / migration / dependency / connectors / docs tests +- [x] Docs: [sports-center-phase-9-0.md](sports-center-phase-9-0.md), [phase-handover/phase-9-0.md](phase-handover/phase-9-0.md), ADR-014, module registry +- [x] No business workflow engines (booking/attendance lifecycle deferred) + +--- + +## Phase 9.1 — Sports Center Membership Catalog ✅ + +- [x] Catalog aggregates: SportCategory, AgeGroup, PricingModel, MembershipPackage, MembershipPlan, MembershipRule, RenewalPolicy, FreezingRule, ExpirationPolicy +- [x] MembershipType extended with catalog refs (package/plan/pricing/age/category/policies), transferability, sort order +- [x] Validators for age range, pricing/billing/rule kinds, money amounts, catalog ref integrity +- [x] Publish-only catalog events; permissions trees for catalog modules +- [x] APIs under `/api/v1/sport-categories|age-groups|pricing-models|membership-packages|membership-plans|membership-rules|renewal-policies|freezing-rules|expiration-policies` +- [x] `/capabilities` endpoint; version `0.9.1.0` +- [x] Alembic `0002_phase_91_membership_catalog` +- [x] Tests: catalog API, tenant isolation, age validation, architecture, docs +- [x] Docs: [sports-center-phase-9-1.md](sports-center-phase-9-1.md), [phase-handover/phase-9-1.md](phase-handover/phase-9-1.md) +- [x] No member enrollment / coach / attendance / booking engines + +--- + +## Phase 9.2 — Sports Center Member Management ✅ + +- [x] Members aggregate + family members, emergency contacts, medical information shells +- [x] Membership assignment (`member_id`) consuming Phase 9.1 catalog types +- [x] Status management: activate / suspend / cancel / freeze / unfreeze +- [x] Membership cards (QR/physical/digital), digital membership passes, waivers (sign), member documents (Storage refs) +- [x] Validators, permissions (`sports_center.members.*` trees), publish-only events +- [x] Alembic `0003_phase_92_member_management`; version `0.9.2.0` +- [x] Tests: members API, tenant isolation, transitions, architecture, migration, docs +- [x] Docs: [sports-center-phase-9-2.md](sports-center-phase-9-2.md), [phase-handover/phase-9-2.md](phase-handover/phase-9-2.md) +- [x] No coach / attendance / booking engines + +--- + +## Phase 9.3 — Sports Center Coach & Staff Management ✅ + +- [x] Extended Coach profile (staff_kind, bio, hire_date, payroll refs) +- [x] Staff certificates, skills, working hours, availability, assignments +- [x] Alembic `0004_phase_93_coach_staff`; version `0.9.3.0` +- [x] Docs + handover [phase-9-3.md](phase-handover/phase-9-3.md) + +--- + +## Phase 9.4 — Sports Center Scheduling & Booking ✅ + +- [x] Sessions, bookings, waiting list, equipment, resource reservations +- [x] Capacity checks and session/resource conflict detection +- [x] Alembic `0005_phase_94_booking`; version `0.9.4.0`; `booking_engine: true` + +--- + +## Phase 9.5 — Sports Center Attendance & Access Control ✅ + +- [x] Attendance records, access logs, check-in/check-out with membership validation +- [x] Alembic `0006_phase_95_attendance`; version `0.9.5.0`; `attendance_engine: true` + +--- + +## Phase 9.6 — Sports Center Training Management ✅ + +- [x] Training programs, exercises, workout plans/assignments, goals, measurements, progress, nutrition, assessments, performance, body composition +- [x] Alembic `0007_phase_96_training`; version `0.9.6.0`; `training_management: true` +- [x] Docs + handover [phase-9-6.md](phase-handover/phase-9-6.md) + +--- + +## Phase 9.7 — Sports Center Competition & Event Management ✅ + +- [x] Competitions, teams, groups, registrations, matches, results, rankings, medals, judges, certificates +- [x] Alembic `0008_phase_97_competitions`; version `0.9.7.0`; `competition_management: true` +- [x] Docs + handover [phase-9-7.md](phase-handover/phase-9-7.md) + +--- + + +## Phase DP-Reg — Delivery & Fleet Platform Registration ✅ + +> Documentation-only registration. No business code, models, APIs, migrations, or repositories. + +- [x] Phase roadmap 10.0–10.10 registered in [phase-manifest.yaml](ai-framework/phase-manifest.yaml) +- [x] Service registered in [service-manifest.yaml](ai-framework/service-manifest.yaml) (`delivery`, `delivery_db`, port 8007) +- [x] Planned modules registered in [module-registry.md](module-registry.md#delivery) +- [x] Delivery terminology added to [glossary.md](glossary.md#delivery--fleet-platform) +- [x] Roadmap doc: [delivery-roadmap.md](delivery-roadmap.md) +- [x] ADR-015 Accepted — Independent Delivery & Fleet Platform +- [x] Module boundaries + phase area README updated +- [x] Handover: [phase-handover/phase-dp-reg.md](phase-handover/phase-dp-reg.md) +- [x] Manifest / cross-reference / documentation / module validation passed + +--- + +## Phase XP-Reg — Experience Platform Registration ✅ + +> Documentation-only registration. No business code, models, APIs, migrations, or repositories. + +- [x] Phase roadmap 11.0–11.10 registered in [phase-manifest.yaml](ai-framework/phase-manifest.yaml) +- [x] Service registered in [service-manifest.yaml](ai-framework/service-manifest.yaml) (`experience`, `experience_db`, port 8008) +- [x] Planned modules registered in [module-registry.md](module-registry.md#experience) +- [x] Experience terminology added to [glossary.md](glossary.md#experience-platform) +- [x] Roadmap doc: [experience-roadmap.md](experience-roadmap.md) +- [x] Phase doc: [experience-phase-xp-reg.md](experience-phase-xp-reg.md) +- [x] ADR-016 Accepted — Independent Enterprise Experience Platform (Torbat Pages) +- [x] Module boundaries + database architecture + schema notes + phase area README updated +- [x] Historical `website_builder` marked prefer-`experience` (ADR-016) +- [x] Handover: [phase-handover/phase-xp-reg.md](phase-handover/phase-xp-reg.md) +- [x] Manifest / cross-reference / documentation / module validation passed + +--- + +## Phase 11.0 — Experience Platform Foundation ✅ + +- [x] Independent `backend/services/experience` (`experience_db`, port **8008**, version **0.11.0.0**, commercial product **Torbat Pages**) +- [x] Foundation aggregates: Workspace, LocaleProfile, ExternalProviderConfig, RenderEngineRegistration, Configuration, Setting, Role/Permission shells, AuditLog +- [x] `GET /health`, `/capabilities`, `/metrics`; permissions `experience.*`; publish-only events +- [x] Provider contracts only (Storage, CRM, Hospitality, Marketplace, Sports, Delivery, Communication, Loyalty, Accounting, AI, Automation, …) +- [x] Alembic `0001_initial`; compose + `.env.example` + `init-dbs.sql` wiring +- [x] Tests: architecture, API, tenant isolation, permissions, migration, dependency, security, docs +- [x] Docs: [experience-phase-11-0.md](experience-phase-11-0.md), [phase-handover/phase-11-0.md](phase-handover/phase-11-0.md) +- [x] No sites/pages/page-builder/theme/template/CMS/forms engines + +--- + +## Phase 11.1 — Experience Sites & Page Resources ✅ + +- [x] Sites under workspaces (multiple sites); draft/publish/archive lifecycle +- [x] Pages-as-resources with page types (landing, menu, bio, portfolio, QR, blog, …) +- [x] Site domain/subdomain binding refs (`experience_site_domains`) +- [x] Permissions `experience.sites.*` / `experience.pages.*` / `experience.domains.*` +- [x] Events `experience.site.*` / `experience.page.*` / `experience.site_domain.*` +- [x] Alembic `0002_phase_111_sites_pages`; version `0.11.1.0` +- [x] Tests: sites API, tenant isolation, publish/archive rules, architecture, migration, docs +- [x] Docs: [experience-phase-11-1.md](experience-phase-11-1.md), [phase-handover/phase-11-1.md](phase-handover/phase-11-1.md) +- [x] No page builder / theme / template engines + +--- + +## Phase 11.2 — Experience Component Library & Versioning ✅ + +- [x] Versioned component catalog (`ExperienceComponent`) with kinds + lifecycle +- [x] Immutable published component versions (`ExperienceComponentVersion`) +- [x] Page composition shells (`PageComponentPlacement` slot pins) +- [x] Policies, specifications, commands, queries; list filter/sort/search +- [x] Permissions `experience.components.*`; events `experience.component*` / `experience.page_component.*` +- [x] Alembic `0003_phase_112_components`; version `0.11.2.0` +- [x] Tests: policies, API flow, immutability, tenant isolation, architecture, migration, docs +- [x] Docs: [experience-phase-11-2.md](experience-phase-11-2.md), [phase-handover/phase-11-2.md](phase-handover/phase-11-2.md) +- [x] No theme / layout engines + +--- + +## Phase 11.3 — Experience Theme & Layout Engine ✅ + +- [x] Replaceable theme packs (`ExperienceTheme`) with tokens + directionality +- [x] Dynamic layouts (`ExperienceLayout`) with regions/slots shells +- [x] Site theme + page layout assignments (swap without rewriting pages) +- [x] Policies, specifications, commands, queries; list filter/sort/search +- [x] Permissions `experience.themes.*` / `experience.layouts.*` +- [x] Events `experience.theme.*` / `experience.layout.*` / `experience.site_theme.*` / `experience.page_layout.*` +- [x] Alembic `0004_phase_113_themes_layouts`; version `0.11.3.0` +- [x] Tests: policies, API flow, tenant isolation, architecture, migration, docs +- [x] Docs: [experience-phase-11-3.md](experience-phase-11-3.md), [phase-handover/phase-11-3.md](phase-handover/phase-11-3.md) +- [x] No template catalog engine + +--- + +## Phase 12.0 — Hospitality Platform Foundation ✅ + +- [x] Independent `backend/services/hospitality` (`hospitality_db`, port **8009**, version **0.12.0.0**, commercial product **Torbat Food**) +- [x] Foundation aggregates: Venue, Branch, DiningArea, DiningTable, Menu, MenuCategory, MenuItem, BundleDefinition, TenantBundle, FeatureToggle, roles/permissions, configuration/settings/events/audit +- [x] Feature-based architecture: bundle licensing + feature toggles + `/capabilities` discovery +- [x] Venue formats catalog (cafe → catering) without hardcoded engines +- [x] Publish-only events `hospitality.*`; permissions `hospitality.*` +- [x] Provider contracts only (Accounting, CRM, Loyalty, Communication, Delivery, Experience/Website, AI, …) +- [x] Alembic `0001_initial`; compose + `.env.example` wiring +- [x] Tests: architecture, API, tenant isolation, permissions, bundles, migration, dependency, security, performance, docs +- [x] Docs: [hospitality-phase-12-0.md](hospitality-phase-12-0.md), [phase-handover/phase-12-0.md](phase-handover/phase-12-0.md), [hospitality-roadmap.md](hospitality-roadmap.md), ADR-017 +- [x] Historical `restaurant-foundation` / restaurant scaffold superseded by hospitality +- [x] No POS / kitchen / ordering / reservation engines + +--- + +## Phase 12.1 — Digital Menu Catalog ✅ + +- [x] Catalog aggregates: Allergen, ModifierGroup, ModifierOption, MenuItemModifier, MenuItemAllergen, AvailabilityWindow, MenuMediaRef, MenuLocalization +- [x] Additive MenuItem fields (prep time, calories, dietary flags, tags, primary_media_ref) +- [x] Catalog APIs + validators + permissions + publish-only events +- [x] Alembic `0002_phase_121_digital_menu_catalog`; version **0.12.1.0**; capabilities `digital_menu_catalog` +- [x] Tests: catalog happy path, tenant isolation, validation, architecture/migration/docs updates +- [x] Docs: [hospitality-phase-12-1.md](hospitality-phase-12-1.md), [phase-handover/phase-12-1.md](phase-handover/phase-12-1.md) +- [x] No POS / kitchen / QR ordering / reservation engines + +--- + +## Phase 12.2 — QR Menu & QR Ordering ✅ + +- [x] Aggregates: QrCode, QrMenuSession, QrOrderingSession, Cart, CartLine +- [x] APIs + validators + permissions + publish-only events +- [x] Alembic `0003_phase_122_qr_menu_ordering`; version **0.12.2.0**; capabilities `qr_menu`, `qr_ordering` +- [x] Tests: QR happy path, tenant isolation, validation, architecture/migration/docs +- [x] Docs: [hospitality-phase-12-2.md](hospitality-phase-12-2.md), [phase-handover/phase-12-2.md](phase-handover/phase-12-2.md) +- [x] No POS / kitchen / payment / reservation engines + +--- + +## Phase 12.3 — Table Service & Reservations ✅ + +- [x] Aggregates: Reservation, TableAssignment, ServiceRequest, WaitlistEntry +- [x] APIs + validators + permissions + publish-only events (status transitions with terminal-state guards) +- [x] Alembic `0004_phase_123_table_service_reservations`; version **0.12.3.0**; capabilities `table_service`, `reservation` +- [x] Tests: reservation/table-assignment/service-request/waitlist happy paths, tenant isolation, validation, architecture/migration/docs +- [x] Docs: [hospitality-phase-12-3.md](hospitality-phase-12-3.md), [phase-handover/phase-12-3.md](phase-handover/phase-12-3.md) +- [x] No POS / kitchen / payment engines + +--- + +## Phase 12.4 — POS Lite ✅ + +- [x] Aggregates: PosRegister, PosShift, PosTicket, PosTicketLine +- [x] APIs + validators + permissions + publish-only events (shift open/close, ticket void) +- [x] Alembic `0005_phase_124_pos_lite`; version **0.12.4.0**; capabilities `pos_lite` +- [x] Tests: register/shift/ticket/line happy path, tenant isolation, quantity validation, architecture/migration/docs +- [x] Docs: [hospitality-phase-12-4.md](hospitality-phase-12-4.md), [phase-handover/phase-12-4.md](phase-handover/phase-12-4.md) +- [x] No POS Pro (payments/discounts/taxes) / kitchen engines + +--- + +## Phase 12.5 — POS Pro ✅ + +- [x] Aggregates: PosPayment, PosDiscount, PosTaxLine, PosFloorPlan, PosStation +- [x] APIs + validators + permissions + publish-only events +- [x] Alembic `0006_phase_125_pos_pro`; version **0.12.5.0**; capabilities `pos_pro` +- [x] Tests: payment/discount/tax/floor-plan/station happy path, tenant isolation, discount percent + payment amount validation, architecture/migration/docs +- [x] Docs: [hospitality-phase-12-5.md](hospitality-phase-12-5.md), [phase-handover/phase-12-5.md](phase-handover/phase-12-5.md) +- [x] No accounting journal posting / real payment gateway / kitchen dispatch + +--- + +## Phase 12.6 — Kitchen ✅ + +- [x] Aggregates: KitchenStation, KitchenTicket, KitchenTicketItem +- [x] APIs + validators + permissions + publish-only events (status transitions with terminal-state guards) +- [x] Alembic `0007_phase_126_kitchen`; version **0.12.6.0**; capabilities `kitchen`, `kitchen_engine` +- [x] Tests: station/ticket/item happy path, tenant isolation, quantity validation, architecture/migration/docs +- [x] Docs: [hospitality-phase-12-6.md](hospitality-phase-12-6.md), [phase-handover/phase-12-6.md](phase-handover/phase-12-6.md) +- [x] No connector integrations yet; kitchen tickets reference POS/QR sources by UUID only + +--- + +## Phase 12.7 — Connectors ✅ + +- [x] Aggregates: ConnectorRegistration, ConnectorDispatch +- [x] `app/providers/mocks.py`: no-op mocks for Accounting/CRM/Loyalty/Communication/Delivery/WebsiteBuilder Protocols (no httpx, no cross-service imports) +- [x] APIs + validators + permissions + publish-only events (dispatch → `sent` if registration active, else `failed`) +- [x] Alembic `0008_phase_127_connectors`; version **0.12.7.0**; capabilities `delivery_integration`, `accounting_integration`, `crm_integration`, `loyalty_integration`, `communication_integration`, `website_integration` +- [x] Tests: registration/dispatch happy path, tenant isolation, code uniqueness, architecture/migration/docs +- [x] Docs: [hospitality-phase-12-7.md](hospitality-phase-12-7.md), [phase-handover/phase-12-7.md](phase-handover/phase-12-7.md) +- [x] No real connector SDK calls; all dispatch responses are locally mocked + +--- + +## Phase 12.8 — Analytics ✅ + +- [x] Aggregates: AnalyticsReportDefinition, AnalyticsSnapshot +- [x] APIs + validators + permissions + publish-only events (`POST /analytics-snapshots/refresh` computes metrics via local `COUNT(*)` queries) +- [x] Alembic `0009_phase_128_analytics`; version **0.12.8.0**; capability `analytics` +- [x] Tests: report/snapshot happy path, tenant isolation, code uniqueness, unknown metric_key rejection, architecture/migration/docs +- [x] Docs: [hospitality-phase-12-8.md](hospitality-phase-12-8.md), [phase-handover/phase-12-8.md](phase-handover/phase-12-8.md) +- [x] No external analytics engine or cross-service reads; local table counts only + +--- + + +## Phase 10.0 — Delivery Platform Foundation ✅ + +- [x] `backend/services/delivery` service scaffold (API / services / repositories / models) +- [x] Foundation aggregates: DeliveryOrganization, DeliveryHub, DeliveryRole, DeliveryPermission, ExternalProviderConfig, RoutingEngineRegistration, DeliveryConfiguration, DeliverySetting, DeliveryAuditLog +- [x] Platform provider contracts (Accounting, CRM, Loyalty, Communication, Notification, Storage, AI, Identity, RoutingEngine, Fleet) +- [x] Publish-only events (`delivery.organization|hub|external_provider|routing_engine|configuration|setting.*`) +- [x] Health `/health`, Capabilities `/capabilities`, Metrics `/metrics` +- [x] APIs under `/api/v1` for organizations, hubs, external-providers, routing-engines, configurations, settings, audit +- [x] Permissions `delivery.*` trees; tenant isolation via `X-Tenant-ID` +- [x] Alembic `0001_initial` + `delivery_db` wiring (compose port 8007); version `0.10.0.0` +- [x] Architecture / API / tenant / permissions / migration / dependency / security / docs tests +- [x] Docs: [delivery-phase-10-0.md](delivery-phase-10-0.md), [phase-handover/phase-10-0.md](phase-handover/phase-10-0.md) +- [x] No driver/fleet/dispatch/routing/tracking engines (deferred) + +--- + +## Phase 10.1 — Driver Management ✅ + +- [x] Aggregates: Driver, DriverCredential, DriverDocument, DriverLifecycleEvent, OutboxEvent +- [x] Lifecycle: activate / suspend / resume / deactivate / block / unblock / archive + append-only history +- [x] Soft delete + optimistic locking; list pagination / filter / sort / search +- [x] Commands, queries, policies, specifications, validators +- [x] APIs `/api/v1/drivers` + `/api/v1/permissions/catalog`; capabilities/metrics `phase: 10.1` +- [x] Permissions `delivery.drivers.*`; events `delivery.driver.*` via transactional outbox +- [x] Alembic `0002_phase_101_drivers`; version **0.10.1.0** +- [x] Tests: lifecycle, credentials/documents, tenant, architecture, migration, permissions, performance indexes, docs +- [x] Docs: [delivery-phase-10-1.md](delivery-phase-10-1.md), [phase-handover/phase-10-1.md](phase-handover/phase-10-1.md) +- [x] No fleet / dispatch / routing / tracking engines (deferred) + +--- + +## Phase HC-Reg — Healthcare Platform Registration ✅ + +> Documentation-only registration. No business code, models, APIs, migrations, or repositories. + +- [x] Phase roadmap 13.0–13.7 documented in [healthcare-roadmap.md](healthcare-roadmap.md) +- [x] Planned modules registered in [module-registry.md](module-registry.md#healthcare) +- [x] Phase area README: [phases/Healthcare/README.md](phases/Healthcare/README.md) +- [x] Phase documents: 13.0 Foundation through 13.7 Delivery Integration +- [x] Roadmap / progress / next-steps integration +- [x] Port **8010** and `healthcare_db` reserved in registry +- [x] No business services modified at registration time + +--- + +## Phase 13.0 — Healthcare Platform Foundation ✅ + +- [x] Independent `backend/services/healthcare` (`healthcare_db`, port **8010**, version **0.13.0.0**, commercial product **Torbat Health**) +- [x] Foundation aggregates: Clinic, Branch, Department, Doctor, Patient, HealthcareRole, HealthcarePermission, ExternalProviderConfig, HealthcareConfiguration, HealthcareSetting, HealthcareAuditLog, OutboxEvent +- [x] Provider contracts only (Communication, CRM, Loyalty, Accounting, Delivery, Storage, AI, Identity) +- [x] Publish-only events `healthcare.clinic.*`, `healthcare.doctor.*`, `healthcare.patient.*`, `healthcare.setting.*` +- [x] Permissions `healthcare.*`; APIs under `/api/v1/clinics|branches|departments|doctors|patients|settings|…` +- [x] Alembic `0001_initial`; compose + `.env.example` + postgres init wiring +- [x] Tests: architecture, API, tenant, permissions, migration, dependency, security, docs +- [x] Docs + handover: [phase-handover/phase-13-0.md](phase-handover/phase-13-0.md) +- [x] No appointment/clinical/pharmacy/delivery engines + +--- + +## Phase 13.1 — Healthcare Appointment Engine ✅ + +- [x] Aggregates: Schedule, AvailabilityWindow, AppointmentType, Appointment, WaitingListEntry +- [x] Lifecycle: book, confirm, cancel, reschedule, no-show; conflict detection on overlapping slots +- [x] Communication reminder via provider mock +- [x] Alembic `0002_phase_131_appointments`; version **0.13.1.0** +- [x] Events `healthcare.appointment.*`, `healthcare.schedule.*` +- [x] Handover: [phase-handover/phase-13-1.md](phase-handover/phase-13-1.md) + +--- + +## Phase 13.2 — Healthcare Doctor Panel ✅ + +- [x] VisitSession, VisitNote, doctor dashboard/queue APIs +- [x] Visit lifecycle: start, finish, defer, call-next; finish → appointment completed +- [x] Alembic `0003_phase_132_doctor_panel`; version **0.13.2.0** +- [x] Handover: [phase-handover/phase-13-2.md](phase-handover/phase-13-2.md) + +--- + +## Phase 13.3 — Healthcare Clinic Management ✅ + +- [x] ClinicService catalog, StaffAssignment, ClinicPolicy, OperatingHoursPolicy +- [x] Policy validators (cancellation vs booking lead time) +- [x] Alembic `0004_phase_133_clinic_management`; version **0.13.3.0** +- [x] Handover: [phase-handover/phase-13-3.md](phase-handover/phase-13-3.md) + +--- + +## Phase 13.4 — Healthcare Patient Portal ✅ + +- [x] Patient-scoped APIs under `/api/v1/patient-portal/*` (profile, appointments, documents, notification preferences) +- [x] Alembic `0005_phase_134_patient_portal`; version **0.13.4.0** +- [x] Handover: [phase-handover/phase-13-4.md](phase-handover/phase-13-4.md) + +--- + +## Phase 13.5 — Healthcare Medical Record ✅ + +- [x] MedicalRecord, Encounter (finalize immutability), Condition, Allergy, MedicationStatement, ImmunizationRecord, access audit +- [x] Alembic `0006_phase_135_medical_record`; version **0.13.5.0** +- [x] Handover: [phase-handover/phase-13-5.md](phase-handover/phase-13-5.md) + +--- + +## Phase 13.6 — Healthcare Pharmacy Network ✅ + +- [x] Pharmacy, PrescriptionOrder, PrescriptionLine, fulfillment lifecycle (accept → ready → picked_up / cancelled) +- [x] Alembic `0007_phase_136_pharmacy_network`; version **0.13.6.0** +- [x] Handover: [phase-handover/phase-13-6.md](phase-handover/phase-13-6.md) + +--- + +## Phase 13.7 — Healthcare Delivery Integration ✅ + +- [x] DeliveryIntegrationRegistration, DeliveryJobIntent, DeliveryJobStatusSnapshot, webhook status ingest +- [x] Delivery provider mock (no cross-service imports); Communication notify on milestones +- [x] Alembic `0008_phase_137_delivery_integration`; version **0.13.7.0** +- [x] **57 backend tests passing**; frontend `/healthcare` shell + BFF + catalog tile +- [x] Handover: [phase-handover/phase-13-7.md](phase-handover/phase-13-7.md) +- [x] Snapshot: [service-snapshots/healthcare.yaml](service-snapshots/healthcare.yaml) + +### Healthcare Platform + +- [x] Phases 13.0–13.7 complete (Foundation through Delivery Integration) + +--- + +## Phase BB-Reg — Beauty Business Platform Registration ✅ + +> Documentation-only registration. No business code, models, APIs, migrations, or repositories. + +- [x] Phase roadmap 14.0–14.7 documented in [beauty-business-roadmap.md](beauty-business-roadmap.md) +- [x] Planned modules registered in [module-registry.md](module-registry.md#beauty_business) +- [x] Phase area README: [phases/BeautyBusiness/README.md](phases/BeautyBusiness/README.md) +- [x] Phase documents: 14.0 Foundation through 14.7 Marketing & Loyalty +- [x] Roadmap / progress / next-steps integration +- [x] Port **8011** and `beauty_business_db` reserved in registry (implementation deferred) +- [x] Documented independence from Healthcare module (separate docs, phases, frontend, permissions, branding) +- [x] No business services modified + +--- + +## Phase 14.0 — Beauty Business Platform Foundation ✅ + +- [x] Independent `backend/services/beauty_business` (`beauty_business_db`, port **8011**, version **0.14.0.0** → **0.14.7.0**, commercial product **Torbat Beauty**) +- [x] Foundation aggregates: BeautyOrganization (incl. `business_format`), BeautyBranch, BeautyRole, BeautyPermission, ExternalProviderConfig, BookingEngineRegistration, BeautyConfiguration, BeautySetting, BeautyAuditLog, OutboxEvent +- [x] `BusinessFormat` enum: salon, barbershop, clinic, laser, skin_care, hair_treatment, spa +- [x] Provider contracts only (CRM, Accounting, Loyalty, Communication, Delivery, Experience, Storage, AI, Identity) +- [x] Publish-only events `beauty_business.*`; permissions `beauty_business.*` +- [x] Alembic `0001_initial`; compose + `.env.example` wiring +- [x] Tests: architecture, API, tenant, permissions, migration, dependency, security, docs +- [x] No booking/catalog/customer engines in 14.0 scope + +--- + +## Phase 14.1 — Beauty Booking Engine ✅ + +- [x] Aggregates: StaffSchedule, StaffAvailability, ScheduleException, AppointmentType, Appointment, AppointmentStatusHistory, WaitingListEntry +- [x] Appointment lifecycle: confirm, check-in, complete, cancel, no-show +- [x] Alembic `0002_phase_141_booking`; APIs under `/staff-schedules`, `/appointments`, `/waiting-list`, … +- [x] Events `beauty_business.appointment.*`; permissions `beauty_business.appointments.*`, `beauty_business.schedules.*` + +--- + +## Phase 14.2 — Beauty Salon Management ✅ + +- [x] Aggregates: TreatmentRoom, Station, BranchPolicy; optional `business_format` on branch +- [x] Alembic `0003_phase_142_salon`; APIs `/treatment-rooms`, `/stations`, `/branch-policies` +- [x] Events `beauty_business.room.*`, `beauty_business.station.*` + +--- + +## Phase 14.3 — Beauty Service Catalog ✅ + +- [x] Aggregates: ServiceCategory, BeautyService (+ ServiceVariant, BranchServiceAssignment, ServiceMediaRef in schema) +- [x] Alembic `0004_phase_143_catalog`; APIs `/service-categories`, `/services` +- [x] Events `beauty_business.service.*` + +--- + +## Phase 14.4 — Beauty Customer Management ✅ + +- [x] Aggregates: CustomerProfile (CRM ref), CustomerPreference, VisitHistoryEntry, CustomerDocumentRef +- [x] Alembic `0005_phase_144_customers`; API `/customers` +- [x] Events `beauty_business.customer.*` + +--- + +## Phase 14.5 — Beauty Package & Membership ✅ + +- [x] Aggregates: PackageDefinition, CustomerPackage, MembershipPlan, CustomerMembership (+ lines/redemptions/lifecycle in schema) +- [x] Alembic `0006_phase_145_packages`; APIs `/package-definitions`, `/membership-plans` +- [x] Events `beauty_business.package.*`, `beauty_business.membership.*` + +--- + +## Phase 14.6 — Beauty Staff & Commission ✅ + +- [x] Aggregates: StaffProfile, StaffSkill, StaffCertificate, CommissionRule, CommissionEntry +- [x] Alembic `0007_phase_146_staff`; APIs `/staff`, `/commission-rules` +- [x] Events `beauty_business.staff.*`, `beauty_business.commission.*` + +--- + +## Phase 14.7 — Beauty Marketing & Loyalty Integration ✅ + +- [x] Aggregates: MarketingCampaignRef, LoyaltyIntegrationConfig, CommunicationIntegrationConfig, IntegrationDispatch +- [x] Provider mocks for CRM, Loyalty, Communication, Accounting, Delivery, Experience +- [x] Alembic `0008_phase_147_marketing`; APIs `/marketing-campaigns`, `/loyalty-integrations`, `/communication-integrations`, `/integration-dispatches` +- [x] **30 backend tests passing**; frontend `/beauty` shell + BFF + catalog tile +- [x] Handover: [phase-handover/phase-14-7.md](phase-handover/phase-14-7.md) +- [x] Snapshot: [service-snapshots/beauty-business.yaml](service-snapshots/beauty-business.yaml) +- [x] Independent from Healthcare (separate DB, port, frontend, permissions) + +### Beauty Business Platform + +- [x] Phases 14.0–14.7 complete (Foundation through Marketing & Loyalty Integration) + +--- + +## Related Documents + +- [Next Steps](next-steps.md) +- [Roadmap](roadmap.md) +- [Frontend docs](frontend/README.md) +- [docs/README.md](README.md) +- [CRM Phase 6.0](crm-phase-6-0.md) +- [CRM Phase 6.1](crm-phase-6-1.md) +- [CRM Phase 6.2](crm-phase-6-2.md) +- [CRM Phase 6.3](crm-phase-6-3.md) +- [Loyalty Phase 7.0](loyalty-phase-7-0.md) +- [Loyalty Phase 7.1](loyalty-phase-7-1.md) +- [Loyalty Phase 7.2](loyalty-phase-7-2.md) +- [Loyalty Phase 7.3](loyalty-phase-7-3.md) +- [Loyalty Phase 7.4](loyalty-phase-7-4.md) +- [Loyalty Phase 7.5](loyalty-phase-7-5.md) +- [Loyalty Phase 7.6](loyalty-phase-7-6.md) +- [Communication Phase 8](communication-phase-8.md) +- [Communication Roadmap](communication-roadmap.md) +- [Phase Handover 8](phase-handover/phase-8.md) +- [Sports Center Phase 9.0](sports-center-phase-9-0.md) +- [Sports Center Phase 9.1](sports-center-phase-9-1.md) +- [Sports Center Phase 9.2](sports-center-phase-9-2.md) +- [Sports Center Phase 9.6](sports-center-phase-9-6.md) +- [Sports Center Phase 9.7](sports-center-phase-9-7.md) +- [Phase Handover 9.0](phase-handover/phase-9-0.md) +- [Phase Handover 9.1](phase-handover/phase-9-1.md) +- [Phase Handover 9.2](phase-handover/phase-9-2.md) +- [Phase Handover 9.6](phase-handover/phase-9-6.md) +- [Phase Handover 9.7](phase-handover/phase-9-7.md) +- [Sports Center Roadmap](sports-center-roadmap.md) +- [Sports Center Service Snapshot](service-snapshots/sports-center.yaml) +- [Delivery Roadmap](delivery-roadmap.md) +- [Delivery Phase 10.0](delivery-phase-10-0.md) +- [Delivery Phase 10.1](delivery-phase-10-1.md) +- [Phase Handover DP-Reg](phase-handover/phase-dp-reg.md) +- [Phase Handover 10.0](phase-handover/phase-10-0.md) +- [Phase Handover 10.1](phase-handover/phase-10-1.md) +- [Experience Roadmap](experience-roadmap.md) +- [Experience Phase XP-Reg](experience-phase-xp-reg.md) +- [Experience Phase 11.0](experience-phase-11-0.md) +- [Experience Phase 11.1](experience-phase-11-1.md) +- [Experience Phase 11.2](experience-phase-11-2.md) +- [Experience Phase 11.3](experience-phase-11-3.md) +- [Phase Handover XP-Reg](phase-handover/phase-xp-reg.md) +- [Phase Handover 11.0](phase-handover/phase-11-0.md) +- [Phase Handover 11.1](phase-handover/phase-11-1.md) +- [AI / Enterprise Development Framework](ai-framework/README.md) +- [Phase Handover AF-Enterprise](phase-handover/phase-af-enterprise.md) +- [Phase Handover AF-Discovery](phase-handover/phase-af-discovery.md) +- [ADR-013](architecture/adr/ADR-013.md) +- [ADR-018](architecture/adr/ADR-018.md) +- [ADR-019](architecture/adr/ADR-019.md) +- [ADR-014](architecture/adr/ADR-014.md) +- [ADR-015](architecture/adr/ADR-015.md) +- [ADR-016](architecture/adr/ADR-016.md) +- [ADR-017](architecture/adr/ADR-017.md) +- [Hospitality Phase 12.0](hospitality-phase-12-0.md) +- [Hospitality Phase 12.1](hospitality-phase-12-1.md) +- [Hospitality Phase 12.2](hospitality-phase-12-2.md) +- [Hospitality Phase 12.3](hospitality-phase-12-3.md) +- [Hospitality Roadmap](hospitality-roadmap.md) +- [Phase Handover 12.0](phase-handover/phase-12-0.md) +- [Phase Handover 12.1](phase-handover/phase-12-1.md) +- [Phase Handover 12.2](phase-handover/phase-12-2.md) +- [Phase Handover 12.3](phase-handover/phase-12-3.md) +- [Healthcare Roadmap](healthcare-roadmap.md) +- [Healthcare Snapshot](service-snapshots/healthcare.yaml) +- [Phase Handover 13.7](phase-handover/phase-13-7.md) +- [Healthcare Phase Area](phases/Healthcare/README.md) +- [Beauty Business Roadmap](beauty-business-roadmap.md) +- [Beauty Business Phase Area](phases/BeautyBusiness/README.md) +- [Beauty Business Snapshot](service-snapshots/beauty-business.yaml) +- [Phase Handover 14.7](phase-handover/phase-14-7.md) diff --git a/docs/reference/api-reference.md b/docs/reference/api-reference.md index 26391c7..cdc8906 100644 --- a/docs/reference/api-reference.md +++ b/docs/reference/api-reference.md @@ -58,12 +58,17 @@ Base: Loyalty service port `8004` (`/api/v1`) — public host `loyalty.torbatyar | Programs | `/api/v1/programs` (+ soft delete) | | Tiers | `/api/v1/tiers` (+ soft delete) | | Members | `/api/v1/members` (+ `/enroll`, `/activate`, `/renew`, `/freeze`, `/resume`, `/cancel`, `/expire`, `/transfer`, `/lifecycle`, soft delete) | -| Point accounts | `/api/v1/point-accounts` (+ soft delete; no balance fields) | -| Rewards | `/api/v1/rewards` (+ soft delete) | -| Campaigns | `/api/v1/campaigns` (+ soft delete) | +| Point accounts | `/api/v1/point-accounts` (+ soft delete; `/balance`, `/ledger`, `/earn`, `/redeem`, `/adjust`, `/expire-points`) | +| Rewards | `/api/v1/rewards` (+ soft delete; `/redeem`, `/redemptions`) | +| Redemptions | `/api/v1/redemptions` (+ `/fulfill`, `/cancel`) | +| Campaigns | `/api/v1/campaigns` (+ soft delete; `/schedule`, `/activate`, `/pause`, `/resume`, `/expire`, `/rules`, `/apply`, `/applications`) | +| Referral programs | `/api/v1/referral-programs` | +| Referral codes | `/api/v1/referral-codes` | +| Referrals | `/api/v1/referrals` (+ `/attribute`, `/convert`, `/reward`, `/cancel`) | +| Wallets | `/api/v1/wallets` (+ `/balance`, `/ledger`, `/credit`, `/debit`, `/adjust`, `/transfer`, soft delete) | | Audit | `/api/v1/audit` | -Phase docs: [loyalty-phase-7-0.md](../loyalty-phase-7-0.md), [loyalty-phase-7-1.md](../loyalty-phase-7-1.md) +Phase docs: [loyalty-phase-7-0.md](../loyalty-phase-7-0.md) … [loyalty-phase-7-6.md](../loyalty-phase-7-6.md) ## Future Services diff --git a/docs/reference/event-catalog.md b/docs/reference/event-catalog.md index f7a731c..f080a7f 100644 --- a/docs/reference/event-catalog.md +++ b/docs/reference/event-catalog.md @@ -1,136 +1,180 @@ -# Event Catalog - -Canonical event types. Envelope rules: [event-driven-architecture.md](../architecture/event-driven-architecture.md). - -## Core Platform - -| event_type | aggregate | Description | -| --- | --- | --- | -| `tenant.created` | tenant | New tenant created | -| `tenant.suspended` | tenant | Tenant suspended | -| `tenant.activated` | tenant | Tenant activated | -| `domain.created` | domain | Domain added | -| `subscription.created` | subscription | Subscription created | -| `subscription.updated` | subscription | Subscription changed | -| `feature_access.changed` | feature_access | Entitlement override/plan effect changed | - -## Identity & Access - -| event_type | Description | -| --- | --- | -| `user.registered` | New user registered | -| `tenant_member.added` | Member added (identity layer) | -| `tenant_member.removed` | Member removed (identity layer) | - -## Accounting (Phases 5.1–5.6) - -| event_type | Description | -| --- | --- | -| `voucher.created` | Voucher drafted | -| `voucher.validated` | Voucher passed validation | -| `voucher.posted` | Voucher posted to ledger | -| `voucher.reversed` | Voucher reversed | -| `journal_entry.created` | Journal entry created via Posting Engine | -| `posting.completed` | Posting operation succeeded | -| `ledger.updated` | General ledger updated | -| `trial_balance.generated` | Trial balance snapshot created | -| `fiscal_period.locked` | Fiscal period locked | -| `cash.received` | Cash receipt recorded | -| `settlement.completed` | AR/AP settlement completed | -| `sales_invoice.posted` | Sales invoice accounting posted | -| `revenue.recognized` | Revenue recognition recorded | - -Full list: `backend/services/accounting/app/events/types.py` - -## CRM (Phases 6.0–6.1) - -| event_type | Description | -| --- | --- | -| `crm.lead.created` | Lead created | -| `crm.lead.updated` | Lead updated | -| `crm.contact.created` | Contact created | -| `crm.organization.created` | Organization created | -| `crm.opportunity.created` | Opportunity created | -| `crm.opportunity.updated` | Opportunity updated | -| `crm.opportunity.won` | Opportunity marked won | -| `crm.opportunity.lost` | Opportunity marked lost | -| `crm.pipeline.changed` | Pipeline updated | -| `crm.stage.changed` | Opportunity stage changed | -| `crm.playbook.assigned` | Sales playbook assigned | -| `crm.playbook.completed` | Playbook assignment completed | -| `crm.forecast.updated` | Sales forecast computed/updated | -| `crm.activity.completed` | Sales activity completed | -| `crm.task.completed` | Sales task completed | -| `crm.meeting.finished` | Meeting finished | -| `crm.call.logged` | Call logged against opportunity | -| `crm.timeline.updated` | Sales timeline entry appended | -| `crm.comment.created` | Collaboration comment created | -| `crm.mention.created` | User mentioned in CRM collaboration | -| `crm.quote.created` | Sales quote created | -| `crm.note.created` | CRM note created | -| `crm.attachment.added` | Attachment reference added | - -Full list: `backend/services/crm/app/events/types.py` - -## Loyalty (Phase 7.0) - -| event_type | Description | -| --- | --- | -| `loyalty.program.created` | Loyalty program created | -| `loyalty.program.updated` | Loyalty program updated | -| `loyalty.program.deleted` | Loyalty program soft-deleted | -| `loyalty.tier.created` | Membership tier created | -| `loyalty.tier.updated` | Membership tier updated | -| `loyalty.tier.deleted` | Membership tier soft-deleted | -| `loyalty.member.created` | Member created | -| `loyalty.member.updated` | Member updated | -| `loyalty.member.enrolled` | Member enrolled / activated | -| `loyalty.member.activated` | Member activated with term/expiry | -| `loyalty.member.renewed` | Membership renewed | -| `loyalty.member.frozen` | Membership frozen | -| `loyalty.member.resumed` | Membership resumed from freeze | -| `loyalty.member.cancelled` | Membership cancelled | -| `loyalty.member.expired` | Membership expired | -| `loyalty.member.transferred` | Membership transferred to another program | -| `loyalty.member.deleted` | Member soft-deleted | -| `loyalty.point_account.opened` | Point account opened | -| `loyalty.point_account.updated` | Point account updated | -| `loyalty.point_account.deleted` | Point account soft-deleted | -| `loyalty.reward.created` | Reward catalog item created | -| `loyalty.reward.updated` | Reward catalog item updated | -| `loyalty.reward.deleted` | Reward soft-deleted | -| `loyalty.campaign.created` | Campaign created | -| `loyalty.campaign.updated` | Campaign updated | -| `loyalty.campaign.deleted` | Campaign soft-deleted | - -Full list: `backend/services/loyalty/app/events/types.py` - -## Communication (Phase 8) - -| event_type | Description | -| --- | --- | -| `communication.message.queued` | Message enqueued | -| `communication.message.sent` | Accepted by provider | -| `communication.message.delivered` | Delivery confirmed | -| `communication.message.failed` | Terminal failure | -| `communication.message.cancelled` | Cancelled | -| `communication.provider.failover` | Provider failover | -| `communication.otp.generated` | OTP issued | -| `communication.otp.verified` | OTP verified | -| `communication.queue.dead_letter` | Queue DLQ | -| `communication.webhook.received` | Inbound webhook | - -Full list: `backend/services/communication/app/events/types.py` - -## Future (reserved) -| Restaurant | `order.placed`, `order.completed` | -| Ecommerce | `order.placed`, `product.updated` | -| Notification | `notification.delivered` (prefer Communication delivery events) | - -When adding events: update this catalog, module registry producers/consumers, and contracts in the same phase. - -## Related Documents - -- [Services Contracts](services-contracts.md) -- [ADR-006](../architecture/adr/ADR-006.md) -- [Module Registry](../module-registry.md) +# Event Catalog + +Canonical event types. Envelope rules: [event-driven-architecture.md](../architecture/event-driven-architecture.md). + +## Core Platform + +| event_type | aggregate | Description | +| --- | --- | --- | +| `tenant.created` | tenant | New tenant created | +| `tenant.suspended` | tenant | Tenant suspended | +| `tenant.activated` | tenant | Tenant activated | +| `domain.created` | domain | Domain added | +| `subscription.created` | subscription | Subscription created | +| `subscription.updated` | subscription | Subscription changed | +| `feature_access.changed` | feature_access | Entitlement override/plan effect changed | + +## Identity & Access + +| event_type | Description | +| --- | --- | +| `user.registered` | New user registered | +| `tenant_member.added` | Member added (identity layer) | +| `tenant_member.removed` | Member removed (identity layer) | + +## Accounting (Phases 5.1–5.6) + +| event_type | Description | +| --- | --- | +| `voucher.created` | Voucher drafted | +| `voucher.validated` | Voucher passed validation | +| `voucher.posted` | Voucher posted to ledger | +| `voucher.reversed` | Voucher reversed | +| `journal_entry.created` | Journal entry created via Posting Engine | +| `posting.completed` | Posting operation succeeded | +| `ledger.updated` | General ledger updated | +| `trial_balance.generated` | Trial balance snapshot created | +| `fiscal_period.locked` | Fiscal period locked | +| `cash.received` | Cash receipt recorded | +| `settlement.completed` | AR/AP settlement completed | +| `sales_invoice.posted` | Sales invoice accounting posted | +| `revenue.recognized` | Revenue recognition recorded | + +Full list: `backend/services/accounting/app/events/types.py` + +## CRM (Phases 6.0–6.1) + +| event_type | Description | +| --- | --- | +| `crm.lead.created` | Lead created | +| `crm.lead.updated` | Lead updated | +| `crm.contact.created` | Contact created | +| `crm.organization.created` | Organization created | +| `crm.opportunity.created` | Opportunity created | +| `crm.opportunity.updated` | Opportunity updated | +| `crm.opportunity.won` | Opportunity marked won | +| `crm.opportunity.lost` | Opportunity marked lost | +| `crm.pipeline.changed` | Pipeline updated | +| `crm.stage.changed` | Opportunity stage changed | +| `crm.playbook.assigned` | Sales playbook assigned | +| `crm.playbook.completed` | Playbook assignment completed | +| `crm.forecast.updated` | Sales forecast computed/updated | +| `crm.activity.completed` | Sales activity completed | +| `crm.task.completed` | Sales task completed | +| `crm.meeting.finished` | Meeting finished | +| `crm.call.logged` | Call logged against opportunity | +| `crm.timeline.updated` | Sales timeline entry appended | +| `crm.comment.created` | Collaboration comment created | +| `crm.mention.created` | User mentioned in CRM collaboration | +| `crm.quote.created` | Sales quote created | +| `crm.note.created` | CRM note created | +| `crm.attachment.added` | Attachment reference added | + +Full list: `backend/services/crm/app/events/types.py` + +## Loyalty (Phase 7.0) + +| event_type | Description | +| --- | --- | +| `loyalty.program.created` | Loyalty program created | +| `loyalty.program.updated` | Loyalty program updated | +| `loyalty.program.deleted` | Loyalty program soft-deleted | +| `loyalty.tier.created` | Membership tier created | +| `loyalty.tier.updated` | Membership tier updated | +| `loyalty.tier.deleted` | Membership tier soft-deleted | +| `loyalty.member.created` | Member created | +| `loyalty.member.updated` | Member updated | +| `loyalty.member.enrolled` | Member enrolled / activated | +| `loyalty.member.activated` | Member activated with term/expiry | +| `loyalty.member.renewed` | Membership renewed | +| `loyalty.member.frozen` | Membership frozen | +| `loyalty.member.resumed` | Membership resumed from freeze | +| `loyalty.member.cancelled` | Membership cancelled | +| `loyalty.member.expired` | Membership expired | +| `loyalty.member.transferred` | Membership transferred to another program | +| `loyalty.member.deleted` | Member soft-deleted | +| `loyalty.point_account.opened` | Point account opened | +| `loyalty.point_account.updated` | Point account updated | +| `loyalty.point_account.deleted` | Point account soft-deleted | +| `loyalty.points.earned` | Points earned on ledger | +| `loyalty.points.redeemed` | Points redeemed on ledger | +| `loyalty.points.adjusted` | Points adjusted on ledger | +| `loyalty.points.expired` | Points expired on ledger | +| `loyalty.reward.created` | Reward catalog item created | +| `loyalty.reward.updated` | Reward catalog item updated | +| `loyalty.reward.deleted` | Reward soft-deleted | +| `loyalty.reward.redeemed` | Reward redeemed (points debited) | +| `loyalty.reward.fulfilled` | Redemption fulfilled | +| `loyalty.reward.redemption_cancelled` | Redemption cancelled / refunded | +| `loyalty.campaign.created` | Campaign created | +| `loyalty.campaign.updated` | Campaign updated | +| `loyalty.campaign.deleted` | Campaign soft-deleted | +| `loyalty.campaign.applied` | Campaign applied (points granted) | +| `loyalty.referral.rewarded` | Referral bonuses granted | +| `loyalty.wallet.opened` | Wallet account opened | +| `loyalty.wallet.credited` | Wallet credited | +| `loyalty.wallet.debited` | Wallet debited | +| `loyalty.wallet.adjusted` | Wallet adjusted | +| `loyalty.wallet.transferred` | Wallet transfer | +| `loyalty.wallet.closed` | Wallet soft-closed | + +Full list: `backend/services/loyalty/app/events/types.py` + +## Communication (Phase 8) + +| event_type | Description | +| --- | --- | +| `communication.message.queued` | Message enqueued | +| `communication.message.sent` | Accepted by provider | +| `communication.message.delivered` | Delivery confirmed | +| `communication.message.failed` | Terminal failure | +| `communication.message.cancelled` | Cancelled | +| `communication.provider.failover` | Provider failover | +| `communication.otp.generated` | OTP issued | +| `communication.otp.verified` | OTP verified | +| `communication.queue.dead_letter` | Queue DLQ | +| `communication.webhook.received` | Inbound webhook | + +Full list: `backend/services/communication/app/events/types.py` + +## Future (reserved) +| Restaurant | `order.placed`, `order.completed` | +| Ecommerce | `order.placed`, `product.updated` | +| Notification | `notification.delivered` (prefer Communication delivery events) | + +When adding events: update this catalog, module registry producers/consumers, and contracts in the same phase. + +## Related Documents + +- [Services Contracts](services-contracts.md) +- [ADR-006](../architecture/adr/ADR-006.md) +- [Module Registry](../module-registry.md) + +## Delivery & Fleet Platform (`delivery`) + +| Event | When | +| --- | --- | +| `delivery.organization.created` | Delivery organization created | +| `delivery.organization.updated` | Delivery organization updated | +| `delivery.hub.created` | Hub created | +| `delivery.hub.updated` | Hub updated | +| `delivery.external_provider.registered` | External provider registered | +| `delivery.external_provider.updated` | External provider updated | +| `delivery.routing_engine.registered` | Routing engine registration created | +| `delivery.routing_engine.updated` | Routing engine registration updated | +| `delivery.configuration.created` | Configuration created | +| `delivery.configuration.updated` | Configuration updated | +| `delivery.setting.upserted` | Setting upserted | +| `delivery.driver.created` | Driver profile created | +| `delivery.driver.updated` | Driver profile updated | +| `delivery.driver.status_changed` | Driver status transition | +| `delivery.driver.activated` | Driver activated | +| `delivery.driver.suspended` | Driver suspended | +| `delivery.driver.resumed` | Driver resumed | +| `delivery.driver.deactivated` | Driver deactivated | +| `delivery.driver.blocked` | Driver blocked | +| `delivery.driver.unblocked` | Driver unblocked | +| `delivery.driver.archived` | Driver archived | +| `delivery.driver.deleted` | Driver soft-deleted | +| `delivery.driver.credential_added` | Driver credential registered | +| `delivery.driver.document_attached` | Driver document metadata attached | diff --git a/docs/roadmap.md b/docs/roadmap.md index 9598e4e..0d8ba26 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,18 +16,38 @@ 3. ~~CRM Core Business Entities~~ (Phase 6.1 done) 4. ~~CRM Enterprise Sales Process Engine~~ (Phase 6.2 done) 5. ~~CRM Enterprise Sales Collaboration~~ (Phase 6.3 done — CRM Core Platform) -8. ~~Loyalty Service Foundation~~ (Phase 7.0 done) -9. ~~Loyalty Membership Engine~~ (Phase 7.1 done) -10. Loyalty Point Engine (Phase 7.2) -11. ~~Enterprise Communication Platform~~ (Phase 8.0–8.10 done) -12. ~~Sports Center Platform Foundation~~ (Phase 9.0 done) -13. ~~Sports Center Membership Catalog~~ (Phase 9.1 done) -14. ~~Sports Center Member Management~~ (Phase 9.2 done) -15. Sports Center Coach & Staff Management (Phase 9.3) -16. Further CRM / cross-module work only when explicitly scoped -17. Notification service as thin consumer of Communication (or merge later) -18. File storage + S3 provider -19. Payment gateway for subscriptions and commerce +6. ~~Loyalty Service Foundation~~ (Phase 7.0 done) +7. ~~Loyalty Membership Engine~~ (Phase 7.1 done) +8. ~~Loyalty Point Engine~~ (Phase 7.2 done) +8a. ~~Loyalty Rewards Engine~~ (Phase 7.3 done) +8b. ~~Loyalty Campaign Engine~~ (Phase 7.4 done) +8c. ~~Loyalty Referral Engine~~ (Phase 7.5 done) +8d. ~~Loyalty Wallet~~ (Phase 7.6 done) +8e. Loyalty Gift Card Platform (Phase 7.7) +9. ~~Enterprise Communication Platform~~ (Phase 8.0–8.10 done — **Production Ready SMS MVP**; future channels → [communication-roadmap.md](communication-roadmap.md)) +10. ~~Sports Center Platform Foundation~~ (Phase 9.0 done) +11. ~~Sports Center Membership Catalog~~ (Phase 9.1 done) +12. ~~Sports Center Member Management~~ (Phase 9.2 done) +13. Sports Center Coach & Staff Management (Phase 9.3) +14. ~~Delivery & Fleet Platform Registration~~ (DP-Reg done) +15. ~~Delivery Platform Foundation~~ (Phase 10.0 done) +16. ~~Delivery Driver Management~~ (Phase 10.1 done) +17. Delivery Fleet & Vehicle Types through Enterprise Validation (Phases 10.2–10.10) +18. ~~Experience Platform Registration~~ (XP-Reg done) +19. ~~Experience Platform Foundation~~ (Phase 11.0 done) +20. ~~Experience Sites & Page Resources~~ (Phase 11.1 done) +21. ~~Experience Component Library & Versioning~~ (Phase 11.2 done) +22. ~~Experience Theme & Layout Engine~~ (Phase 11.3 done) +23. Experience Template System (Phase 11.4) +24. Experience Localization through Enterprise Validation (Phases 11.5–11.10) +25. Further CRM / cross-module work only when explicitly scoped +26. Notification service as thin consumer of Communication (or merge later) +27. File storage + S3 provider +28. Payment gateway for subscriptions and commerce +29. ~~Healthcare Platform Registration~~ (HC-Reg done — docs only) +30. Healthcare Platform Foundation through Delivery Integration (Phases 13.0–13.7) +31. ~~Beauty Business Platform Registration~~ (BB-Reg done — docs only) +32. Beauty Business Platform Foundation through Marketing & Loyalty (Phases 14.0–14.7) ## Longer Term @@ -43,15 +63,24 @@ - [Accounting](phases/Accounting/README.md) - [CRM](phases/CRM/README.md) - [Loyalty](phases/Loyalty/README.md) +- [Communication Roadmap](communication-roadmap.md) - [Restaurant](phases/Restaurant/README.md) - [Sports Center](phases/SportsCenter/README.md) - [Sports Center Roadmap](sports-center-roadmap.md) +- [Delivery](phases/Delivery/README.md) +- [Delivery Roadmap](delivery-roadmap.md) +- [Experience](phases/Experience/README.md) +- [Experience Roadmap](experience-roadmap.md) - [Marketplace](phases/Marketplace/README.md) - [Automation](phases/Automation/README.md) - [AI](phases/AI/README.md) - [Future](phases/Future/README.md) +- [Healthcare](phases/Healthcare/README.md) +- [Healthcare Roadmap](healthcare-roadmap.md) +- [Beauty Business](phases/BeautyBusiness/README.md) +- [Beauty Business Roadmap](beauty-business-roadmap.md) -Implementation of every future phase must follow the [AI Development Framework](ai-framework/README.md). +Implementation of every future phase must follow the [Enterprise / AI Development Framework](ai-framework/README.md) ([ADR-013](architecture/adr/ADR-013.md), [ADR-018](architecture/adr/ADR-018.md), [ADR-019](architecture/adr/ADR-019.md)). Production readiness is automatic via Enterprise Phase Discovery, Definition of Done, mandatory artifacts, and enterprise completeness — no extra prompt required. ## Related Documents @@ -62,3 +91,9 @@ Implementation of every future phase must follow the [AI Development Framework]( - [Phase Manifest](ai-framework/phase-manifest.yaml) - [Sports Center Roadmap](sports-center-roadmap.md) - [ADR-014](architecture/adr/ADR-014.md) +- [ADR-015](architecture/adr/ADR-015.md) +- [Experience Roadmap](experience-roadmap.md) +- [Communication Roadmap](communication-roadmap.md) +- [ADR-016](architecture/adr/ADR-016.md) +- [Healthcare Roadmap](healthcare-roadmap.md) +- [Beauty Business Roadmap](beauty-business-roadmap.md) diff --git a/docs/service-snapshots/loyalty.yaml b/docs/service-snapshots/loyalty.yaml new file mode 100644 index 0000000..fec5fb7 --- /dev/null +++ b/docs/service-snapshots/loyalty.yaml @@ -0,0 +1,112 @@ +# Loyalty Platform service snapshot — regenerated after each Complete phase. +# Spec: docs/ai-framework/service-snapshot-policy.md + +schema_version: 1 +snapshot_version: 5 + +service_name: loyalty +commercial_product: Torbat Loyalty +current_version: "0.7.6.0" +current_phase: loyalty-7.6 +last_completed_phase: loyalty-7.6 +next_phase: loyalty-7.7 + +completed_phases: + - loyalty-7.0 + - loyalty-7.1 + - loyalty-7.2 + - loyalty-7.3 + - loyalty-7.4 + - loyalty-7.5 + - loyalty-7.6 +remaining_phases: + - loyalty-7.7 + - loyalty-7.8 + - loyalty-7.9 + - loyalty-7.10 + +registered_modules: + - programs + - tiers + - members + - membership_lifecycle + - point_accounts + - point_ledger + - rewards + - reward_redemptions + - campaigns + - campaign_applications + - referral_programs + - referral_codes + - referral_attributions + - wallet_accounts + - wallet_ledger + - audit + - outbox + +enabled_capabilities: + - foundation + - membership_engine + - point_engine + - rewards_engine + - campaign_engine + - referral_engine + - wallet_engine + +enabled_bundles: + - torbat_loyalty + +public_apis: + - { method: POST, path: /api/v1/wallets, permission: loyalty.wallets.create } + - { method: GET, path: "/api/v1/wallets/{id}/balance", permission: loyalty.wallets.view } + - { method: GET, path: "/api/v1/wallets/{id}/ledger", permission: loyalty.wallets.view } + - { method: POST, path: "/api/v1/wallets/{id}/credit", permission: loyalty.wallets.credit } + - { method: POST, path: "/api/v1/wallets/{id}/debit", permission: loyalty.wallets.debit } + - { method: POST, path: "/api/v1/wallets/{id}/adjust", permission: loyalty.wallets.adjust } + - { method: POST, path: "/api/v1/wallets/{id}/transfer", permission: loyalty.wallets.transfer } + - { method: POST, path: "/api/v1/wallets/{id}/delete", permission: loyalty.wallets.delete } + - { method: POST, path: "/api/v1/point-accounts/{id}/earn", permission: loyalty.points.earn } + - { method: POST, path: "/api/v1/rewards/{id}/redeem", permission: loyalty.rewards.redeem } + - { method: POST, path: "/api/v1/campaigns/{id}/apply", permission: loyalty.campaigns.apply } + - { method: POST, path: /api/v1/referrals/attribute, permission: loyalty.referrals.attribute } + +published_events: + - { event_type: loyalty.wallet.opened, version: "1" } + - { event_type: loyalty.wallet.credited, version: "1" } + - { event_type: loyalty.wallet.debited, version: "1" } + - { event_type: loyalty.wallet.adjusted, version: "1" } + - { event_type: loyalty.wallet.transferred, version: "1" } + - { event_type: loyalty.wallet.closed, version: "1" } + - { event_type: loyalty.points.earned, version: "1" } + - { event_type: loyalty.reward.redeemed, version: "1" } + - { event_type: loyalty.campaign.applied, version: "1" } + - { event_type: loyalty.referral.rewarded, version: "1" } + +permission_prefix: loyalty.* + +active_adrs: + - ADR-001 + - ADR-003 + - ADR-006 + - ADR-011 + +integration_contracts: + - NotificationProvider + - AnalyticsProvider + - Customer360Provider + - AIProvider + - ModuleIntegrationProvider + - CrmReferenceProvider + - CommunicationProvider + - FileStorageProvider + +known_limitations: + - No payment-gateway settlement for wallet credits + - Gift Card Platform not implemented (7.7) + - No automatic expire/activate schedulers + - Outbox flush still platform-bus maturity item + +open_todos: [] + +last_handover_reference: docs/phase-handover/phase-7-6.md +last_updated: "2026-07-26" diff --git a/infrastructure/deploy/.env.production.example b/infrastructure/deploy/.env.production.example index 6e56299..5f04cf5 100644 --- a/infrastructure/deploy/.env.production.example +++ b/infrastructure/deploy/.env.production.example @@ -82,6 +82,7 @@ NEXT_PUBLIC_KEYCLOAK_REALM=superapp NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend NEXT_PUBLIC_ACCOUNTING_API_URL=https://accounting.torbatyar.ir NEXT_PUBLIC_CRM_API_URL=https://crm.torbatyar.ir +NEXT_PUBLIC_HEALTHCARE_API_URL=https://healthcare.torbatyar.ir ACCOUNTING_SERVICE_URL=http://accounting-service:8002 ACCOUNTING_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/accounting_db @@ -97,6 +98,12 @@ LOYALTY_SERVICE_URL=http://loyalty-service:8004 LOYALTY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/loyalty_db LOYALTY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/loyalty_db LOYALTY_SERVICE_NAME=loyalty-service + +HEALTHCARE_SERVICE_URL=http://healthcare-service:8010 +HEALTHCARE_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/healthcare_db +HEALTHCARE_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/healthcare_db +HEALTHCARE_SERVICE_NAME=healthcare-service + KEYCLOAK_SERVER_URL=http://keycloak:8080 KEYCLOAK_REALM=superapp diff --git a/infrastructure/nginx/torbatyar.ir.conf b/infrastructure/nginx/torbatyar.ir.conf index 6bf1b05..74e060d 100644 --- a/infrastructure/nginx/torbatyar.ir.conf +++ b/infrastructure/nginx/torbatyar.ir.conf @@ -33,11 +33,23 @@ upstream torbatyar_loyalty { server 192.168.10.162:8004; keepalive 16; } +upstream torbatyar_healthcare { + server 192.168.10.162:8010; + keepalive 16; +} +upstream torbatyar_beauty { + server 192.168.10.162:8011; + keepalive 16; +} +upstream torbatyar_hospitality { + server 192.168.10.162:8009; + keepalive 16; +} # Force HTTPS for platform hosts server { listen 192.168.10.156:80; - server_name torbatyar.ir www.torbatyar.ir api.torbatyar.ir identity.torbatyar.ir auth.torbatyar.ir accounting.torbatyar.ir crm.torbatyar.ir loyalty.torbatyar.ir; + server_name torbatyar.ir www.torbatyar.ir api.torbatyar.ir identity.torbatyar.ir auth.torbatyar.ir accounting.torbatyar.ir crm.torbatyar.ir loyalty.torbatyar.ir healthcare.torbatyar.ir beauty.torbatyar.ir hospitality.torbatyar.ir; location /.well-known/acme-challenge/ { root /var/www/html; @@ -80,8 +92,8 @@ server { listen 192.168.10.156:443 ssl; server_name torbatyar.ir www.torbatyar.ir *.torbatyar.ir; - ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; + ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; @@ -104,8 +116,8 @@ server { listen 192.168.10.156:443 ssl; server_name api.torbatyar.ir; - ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; + ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; @@ -125,8 +137,8 @@ server { listen 192.168.10.156:443 ssl; server_name identity.torbatyar.ir; - ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; + ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; @@ -146,8 +158,8 @@ server { listen 192.168.10.156:443 ssl; server_name auth.torbatyar.ir; - ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; + ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; @@ -170,8 +182,8 @@ server { listen 192.168.10.156:443 ssl; server_name accounting.torbatyar.ir; - ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; + ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; @@ -191,8 +203,8 @@ server { listen 192.168.10.156:443 ssl; server_name crm.torbatyar.ir; - ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; + ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; @@ -212,8 +224,8 @@ server { listen 192.168.10.156:443 ssl; server_name loyalty.torbatyar.ir; - ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; + ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; @@ -228,3 +240,66 @@ server { client_max_body_size 50m; } } + +server { + listen 192.168.10.156:443 ssl; + server_name healthcare.torbatyar.ir; + + ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://torbatyar_healthcare; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600s; + client_max_body_size 50m; + } +} + +server { + listen 192.168.10.156:443 ssl; + server_name beauty.torbatyar.ir; + + ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://torbatyar_beauty; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600s; + client_max_body_size 50m; + } +} + +server { + listen 192.168.10.156:443 ssl; + server_name hospitality.torbatyar.ir; + + ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://torbatyar_hospitality; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600s; + client_max_body_size 50m; + } +} diff --git a/infrastructure/postgres/init-dbs.sql b/infrastructure/postgres/init-dbs.sql index 3ba1bdc..4b9b844 100644 --- a/infrastructure/postgres/init-dbs.sql +++ b/infrastructure/postgres/init-dbs.sql @@ -4,3 +4,6 @@ CREATE DATABASE crm_db; CREATE DATABASE communication_db; CREATE DATABASE loyalty_db; CREATE DATABASE sports_center_db; +CREATE DATABASE hospitality_db; +CREATE DATABASE experience_db; +CREATE DATABASE healthcare_db; diff --git a/scripts/deploy_loyalty_prod.py b/scripts/deploy_loyalty_prod.py new file mode 100644 index 0000000..edb368e --- /dev/null +++ b/scripts/deploy_loyalty_prod.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Deploy Loyalty service (Phases 7.0–7.6) to production without wiping .env secrets.""" +from __future__ import annotations + +import base64 +import os +import sys +import tarfile +from pathlib import Path + +import paramiko + +ROOT = Path(__file__).resolve().parents[1] +APP_HOST = "192.168.10.162" +APP_USER = "morteza" +APP_PASS = "Moli@5404" +NGX_HOST = "192.168.10.156" +NGX_USER = "torbatyaruser" +NGX_PASS = "Moli@5404" +REMOTE_DIR = "/home/morteza/torbatyar" + +EXCLUDE_DIRS = { + ".git", + "node_modules", + ".next", + ".venv", + "venv", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + ".idea", + ".vscode", + "agent-transcripts", +} +EXCLUDE_FILES = {".DS_Store", "Thumbs.db", ".env"} + +ENV_UPSERT = { + "LOYALTY_SERVICE_URL": "http://loyalty-service:8004", + "LOYALTY_DATABASE_URL": "postgresql+asyncpg://superapp:superapp_password@postgres:5432/loyalty_db", + "LOYALTY_DATABASE_URL_SYNC": "postgresql+psycopg://superapp:superapp_password@postgres:5432/loyalty_db", + "LOYALTY_SERVICE_NAME": "loyalty-service", + "KEYCLOAK_SERVER_URL": "http://keycloak:8080", + "KEYCLOAK_REALM": "superapp", +} + + +def connect(host: str, user: str, password: str) -> paramiko.SSHClient: + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + c.connect( + host, + username=user, + password=password, + timeout=30, + allow_agent=False, + look_for_keys=False, + ) + return c + + +def run( + client: paramiko.SSHClient, + cmd: str, + *, + sudo: bool = False, + password: str = "", + timeout: int = 600, +) -> tuple[int, str]: + cmd = cmd.replace("\r\n", "\n").replace("\r", "\n").strip() + "\n" + b64 = base64.b64encode(cmd.encode()).decode() + if sudo: + full = f"echo {password!r} | sudo -S -p '' bash -c 'echo {b64} | base64 -d | bash'" + else: + full = f"bash -lc 'echo {b64} | base64 -d | bash'" + print( + f"\n>>> {'[sudo] ' if sudo else ''}{cmd[:220].replace(chr(10), ' | ')}" + f"{'...' if len(cmd) > 220 else ''}" + ) + _stdin, stdout, stderr = client.exec_command(full, timeout=timeout, get_pty=True) + out = stdout.read().decode("utf-8", errors="replace") + err = stderr.read().decode("utf-8", errors="replace") + code = stdout.channel.recv_exit_status() + text = out + (("\n" + err) if err.strip() else "") + printable = text[-5000:] if len(text) > 5000 else text + try: + print(printable) + except UnicodeEncodeError: + print(printable.encode("ascii", "replace").decode("ascii")) + return code, text + + +def should_exclude(path: Path) -> bool: + parts = set(path.parts) + if parts & EXCLUDE_DIRS: + return True + if path.name in EXCLUDE_FILES: + return True + if path.suffix in {".pyc", ".log"}: + return True + return False + + +def make_tarball() -> Path: + out = ROOT / "scripts" / "_deploy_loyalty_bundle.tar.gz" + print(f"Creating tarball {out} ...") + with tarfile.open(out, "w:gz") as tar: + for dirpath, dirnames, filenames in os.walk(ROOT): + p = Path(dirpath) + dirnames[:] = [ + d for d in dirnames if d not in EXCLUDE_DIRS and not d.startswith(".cursor") + ] + rel_dir = p.relative_to(ROOT) + if should_exclude(rel_dir) and rel_dir != Path("."): + continue + for name in filenames: + fp = p / name + rel = fp.relative_to(ROOT) + if should_exclude(rel): + continue + if rel.as_posix().startswith("scripts/_deploy"): + continue + tar.add(fp, arcname=str(rel).replace("\\", "/")) + print(f"Tarball size: {out.stat().st_size / 1024 / 1024:.1f} MB") + return out + + +def upload_file(client: paramiko.SSHClient, local: Path, remote: str) -> None: + sftp = client.open_sftp() + print(f"Uploading {local} -> {remote}") + sftp.put(str(local), remote) + sftp.close() + + +def put_text_sudo( + client: paramiko.SSHClient, password: str, remote: str, content: str +) -> None: + b64 = base64.b64encode(content.encode()).decode() + cmd = ( + f"mkdir -p $(dirname {remote!r}) && echo {b64} | base64 -d > {remote!r} " + f"&& chmod 644 {remote!r}" + ) + code, _ = run(client, cmd, sudo=True, password=password, timeout=60) + if code != 0: + raise RuntimeError(f"failed writing {remote}") + + +def merge_env_remote(client: paramiko.SSHClient) -> None: + py = f""" +from pathlib import Path +p = Path({REMOTE_DIR!r}) / '.env' +text = p.read_text(encoding='utf-8') if p.exists() else '' +lines = text.splitlines() +keys = {ENV_UPSERT!r} +seen = set() +out = [] +for line in lines: + if not line.strip() or line.lstrip().startswith('#') or '=' not in line: + out.append(line) + continue + k = line.split('=', 1)[0].strip() + if k in keys: + out.append(f"{{k}}={{keys[k]}}") + seen.add(k) + else: + out.append(line) +for k, v in keys.items(): + if k not in seen: + out.append(f"{{k}}={{v}}") +p.write_text('\\n'.join(out).rstrip() + '\\n', encoding='utf-8') +print('ENV_MERGED', len(keys)) +""" + b64 = base64.b64encode(py.encode()).decode() + code, _ = run( + client, + f"python3 -c \"import base64; exec(base64.b64decode('{b64}').decode())\"", + ) + if code != 0: + raise RuntimeError("env merge failed") + + +def main() -> int: + tarball = make_tarball() + + app = connect(APP_HOST, APP_USER, APP_PASS) + try: + run(app, f"mkdir -p {REMOTE_DIR}") + upload_file(app, tarball, "/tmp/torbatyar_loyalty_bundle.tar.gz") + code, _ = run( + app, + f""" +set -e +cd {REMOTE_DIR} +if [ -f .env ]; then cp .env /tmp/torbatyar.env.bak; fi +chown -R {APP_USER}:{APP_USER} {REMOTE_DIR}/backend {REMOTE_DIR}/docs {REMOTE_DIR}/docker-compose.yml {REMOTE_DIR}/infrastructure 2>/dev/null || true +chmod -R u+rwX {REMOTE_DIR}/backend {REMOTE_DIR}/docs 2>/dev/null || true +tar --overwrite -xzf /tmp/torbatyar_loyalty_bundle.tar.gz -C {REMOTE_DIR} +if [ -f /tmp/torbatyar.env.bak ]; then cp /tmp/torbatyar.env.bak .env; fi +chown -R {APP_USER}:{APP_USER} {REMOTE_DIR}/backend {REMOTE_DIR}/docs 2>/dev/null || true +rm -f /tmp/torbatyar_loyalty_bundle.tar.gz +ls -la {REMOTE_DIR}/backend/services/loyalty | head +""", + sudo=True, + password=APP_PASS, + timeout=300, + ) + if code != 0: + raise RuntimeError("extract failed") + + merge_env_remote(app) + + run( + app, + f""" +set -e +cd {REMOTE_DIR} +sg docker -c "docker compose exec -T postgres psql -U superapp -d postgres -tc \\"SELECT 1 FROM pg_database WHERE datname='loyalty_db'\\" | grep -q 1 || docker compose exec -T postgres psql -U superapp -d postgres -c 'CREATE DATABASE loyalty_db;'" +""", + timeout=120, + ) + + code, _ = run( + app, + f""" +set -e +cd {REMOTE_DIR} +sg docker -c 'docker compose up -d --build loyalty-service' +sg docker -c 'docker compose ps' +sg docker -c 'docker compose logs --tail=80 loyalty-service' +""", + timeout=1800, + ) + if code != 0: + code, _ = run( + app, + f""" +set -e +cd {REMOTE_DIR} +docker compose up -d --build loyalty-service +docker compose ps +docker compose logs --tail=120 loyalty-service +""", + sudo=True, + password=APP_PASS, + timeout=1800, + ) + if code != 0: + raise RuntimeError("compose up failed") + + code, health = run( + app, + """ +set -e +for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do + if curl -sf http://127.0.0.1:8004/health; then + echo + echo LOYALTY_HEALTH_OK + exit 0 + fi + sleep 4 +done +curl -sv http://127.0.0.1:8004/health || true +docker compose -f /home/morteza/torbatyar/docker-compose.yml logs --tail=80 loyalty-service || true +exit 1 +""", + timeout=180, + ) + if code != 0: + raise RuntimeError(f"Loyalty health failed: {health}") + finally: + app.close() + + ngx = connect(NGX_HOST, NGX_USER, NGX_PASS) + try: + conf = (ROOT / "infrastructure" / "nginx" / "torbatyar.ir.conf").read_text( + encoding="utf-8" + ) + put_text_sudo(ngx, NGX_PASS, "/etc/nginx/conf.d/torbatyar.ir.conf", conf) + certbot = r""" +set -e +mkdir -p /var/www/html +certbot certonly --webroot -w /var/www/html \ + -d torbatyar.ir -d www.torbatyar.ir \ + -d api.torbatyar.ir -d identity.torbatyar.ir -d auth.torbatyar.ir \ + -d accounting.torbatyar.ir -d crm.torbatyar.ir -d loyalty.torbatyar.ir \ + --non-interactive --agree-tos --email support@torbatyar.ir --expand || true +nginx -t && systemctl reload nginx && echo NGINX_RELOADED +""" + code, _ = run(ngx, certbot, sudo=True, password=NGX_PASS, timeout=300) + if code != 0: + raise RuntimeError("nginx reload / certbot failed") + finally: + ngx.close() + + print("\n=== LOYALTY DEPLOY FINISHED ===") + print("API: https://loyalty.torbatyar.ir/health") + print("Local: http://192.168.10.162:8004/health") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: + print(f"FATAL: {exc}", file=sys.stderr) + raise