Ship Loyalty phases 7.2-7.6 (points through wallet) with production deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mortezakoohjani 2026-07-26 21:27:22 +03:30
parent e140908034
commit 071c484530
84 changed files with 11958 additions and 809 deletions

View File

@ -1,143 +1,172 @@
# ============================================================ # ============================================================
# SuperApp SaaS Platform - Environment Configuration Example # SuperApp SaaS Platform - Environment Configuration Example
# این فایل را به .env کپی کنید و مقادیر واقعی را جایگزین نمایید. # این فایل را به .env کپی کنید و مقادیر واقعی را جایگزین نمایید.
# ============================================================ # ============================================================
# ---- General ---- # ---- General ----
ENVIRONMENT=development # development | staging | production ENVIRONMENT=development # development | staging | production
DEBUG=true DEBUG=true
LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR
SERVICE_NAME=core-service SERVICE_NAME=core-service
API_V1_PREFIX=/api/v1 API_V1_PREFIX=/api/v1
# ---- Platform Branding (White-label) ---- # ---- Platform Branding (White-label) ----
# برند نباید در کد hardcode شود؛ از این مقادیر استفاده کنید. # برند نباید در کد hardcode شود؛ از این مقادیر استفاده کنید.
PLATFORM_NAME=Torbatyar PLATFORM_NAME=Torbatyar
PLATFORM_SUPPORT_EMAIL=support@example.com PLATFORM_SUPPORT_EMAIL=support@example.com
PLATFORM_PRIMARY_COLOR=#0284c7 PLATFORM_PRIMARY_COLOR=#0284c7
PLATFORM_SECONDARY_COLOR=#0f172a PLATFORM_SECONDARY_COLOR=#0f172a
# دامنه پایه برای تخصیص خودکار زیردامنه tenant در onboarding (مثلاً <slug>.torbatyar.xyz) # دامنه پایه برای تخصیص خودکار زیردامنه tenant در onboarding (مثلاً <slug>.torbatyar.xyz)
# دامنه پایه برای تخصیص خودکار زیردامنه tenant (مثلاً <slug>.torbatyar.ir) # دامنه پایه برای تخصیص خودکار زیردامنه tenant (مثلاً <slug>.torbatyar.ir)
PLATFORM_BASE_DOMAIN=torbatyar.ir PLATFORM_BASE_DOMAIN=torbatyar.ir
# ---- Auto SSL for tenant domains (Celery → SSH → nginx certbot) ---- # ---- Auto SSL for tenant domains (Celery → SSH → nginx certbot) ----
SSL_PROVISION_ENABLED=false SSL_PROVISION_ENABLED=false
SSL_PROVISION_HOST=192.168.10.156 SSL_PROVISION_HOST=192.168.10.156
SSL_PROVISION_USER=torbatyaruser SSL_PROVISION_USER=torbatyaruser
SSL_PROVISION_PASSWORD= SSL_PROVISION_PASSWORD=
SSL_PROVISION_SCRIPT=/opt/torbatyar/bin/provision_ssl.py SSL_PROVISION_SCRIPT=/opt/torbatyar/bin/provision_ssl.py
# ---- Core Database (core_platform_db) ---- # ---- Core Database (core_platform_db) ----
POSTGRES_HOST=postgres POSTGRES_HOST=postgres
POSTGRES_PORT=5432 POSTGRES_PORT=5432
POSTGRES_USER=superapp POSTGRES_USER=superapp
POSTGRES_PASSWORD=superapp_password POSTGRES_PASSWORD=superapp_password
POSTGRES_DB=core_platform_db POSTGRES_DB=core_platform_db
# async driver برای FastAPI/SQLAlchemy # async driver برای FastAPI/SQLAlchemy
DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/core_platform_db DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/core_platform_db
# sync driver برای Alembic # sync driver برای Alembic
DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/core_platform_db DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/core_platform_db
# ---- Redis ---- # ---- Redis ----
REDIS_HOST=redis REDIS_HOST=redis
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_DB=0 REDIS_DB=0
REDIS_URL=redis://redis:6379/0 REDIS_URL=redis://redis:6379/0
FEATURE_ACCESS_CACHE_TTL=300 # ثانیه FEATURE_ACCESS_CACHE_TTL=300 # ثانیه
TENANT_METADATA_CACHE_TTL=300 TENANT_METADATA_CACHE_TTL=300
# ---- Celery ---- # ---- Celery ----
CELERY_BROKER_URL=redis://redis:6379/1 CELERY_BROKER_URL=redis://redis:6379/1
CELERY_RESULT_BACKEND=redis://redis:6379/2 CELERY_RESULT_BACKEND=redis://redis:6379/2
# ---- Keycloak / SSO (فاز ۲ — فعال در Docker) ---- # ---- Keycloak / SSO (فاز ۲ — فعال در Docker) ----
# Production: auth.torbatyar.ir پشت nginx روی 192.168.10.156 # Production: auth.torbatyar.ir پشت nginx روی 192.168.10.156
# Dev محلی: می‌توانید hosts را به 127.0.0.1 اشاره دهید. # Dev محلی: می‌توانید hosts را به 127.0.0.1 اشاره دهید.
KEYCLOAK_ENABLED=true KEYCLOAK_ENABLED=true
KEYCLOAK_SERVER_URL=http://keycloak:8080 KEYCLOAK_SERVER_URL=http://keycloak:8080
KEYCLOAK_PUBLIC_URL=https://auth.torbatyar.ir KEYCLOAK_PUBLIC_URL=https://auth.torbatyar.ir
KEYCLOAK_HOSTNAME=auth.torbatyar.ir KEYCLOAK_HOSTNAME=auth.torbatyar.ir
KEYCLOAK_HOSTNAME_STRICT_HTTPS=false KEYCLOAK_HOSTNAME_STRICT_HTTPS=false
KEYCLOAK_REALM=superapp KEYCLOAK_REALM=superapp
KEYCLOAK_CLIENT_ID=core-service KEYCLOAK_CLIENT_ID=core-service
KEYCLOAK_CLIENT_SECRET=change-me KEYCLOAK_CLIENT_SECRET=change-me
JWT_ALGORITHM=RS256 JWT_ALGORITHM=RS256
JWT_AUDIENCE=account JWT_AUDIENCE=account
JWT_VERIFY_SIGNATURE=true JWT_VERIFY_SIGNATURE=true
# در production و Docker باید true باشد؛ در تست‌ها false # در production و Docker باید true باشد؛ در تست‌ها false
AUTH_REQUIRED=true AUTH_REQUIRED=true
# ---- Identity & Access Service ---- # ---- Identity & Access Service ----
IDENTITY_SERVICE_URL=http://identity-access-service:8001 IDENTITY_SERVICE_URL=http://identity-access-service:8001
IDENTITY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/identity_access_db 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 IDENTITY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/identity_access_db
KEYCLOAK_FRONTEND_CLIENT_ID=superapp-frontend KEYCLOAK_FRONTEND_CLIENT_ID=superapp-frontend
KEYCLOAK_IDENTITY_CLIENT_SECRET=change-me-identity KEYCLOAK_IDENTITY_CLIENT_SECRET=change-me-identity
IDENTITY_KEYCLOAK_CLIENT_ID=identity-access-service IDENTITY_KEYCLOAK_CLIENT_ID=identity-access-service
FRONTEND_CALLBACK_URL=https://torbatyar.ir/auth/callback 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 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 CORE_SERVICE_URL=http://core-service:8000
# ---- Accounting Service ---- # ---- Accounting Service ----
ACCOUNTING_SERVICE_URL=http://accounting-service:8002 ACCOUNTING_SERVICE_URL=http://accounting-service:8002
ACCOUNTING_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/accounting_db 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_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/accounting_db
ACCOUNTING_SERVICE_NAME=accounting-service ACCOUNTING_SERVICE_NAME=accounting-service
# ---- CRM Service ---- # ---- CRM Service ----
CRM_SERVICE_URL=http://crm-service:8003 CRM_SERVICE_URL=http://crm-service:8003
CRM_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/crm_db 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_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/crm_db
CRM_SERVICE_NAME=crm-service CRM_SERVICE_NAME=crm-service
# ---- Loyalty Service ---- # ---- Loyalty Service ----
LOYALTY_SERVICE_URL=http://loyalty-service:8004 LOYALTY_SERVICE_URL=http://loyalty-service:8004
LOYALTY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/loyalty_db 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_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/loyalty_db
LOYALTY_SERVICE_NAME=loyalty-service LOYALTY_SERVICE_NAME=loyalty-service
# ---- Communication Service (Phase 8) ---- # ---- Communication Service (Phase 8) ----
COMMUNICATION_SERVICE_URL=http://communication-service:8005 COMMUNICATION_SERVICE_URL=http://communication-service:8005
COMMUNICATION_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/communication_db 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_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/communication_db
COMMUNICATION_SERVICE_NAME=communication-service COMMUNICATION_SERVICE_NAME=communication-service
# ---- Sports Center Service (Phase 9.0) ---- # ---- Sports Center Service (Phase 9.0) ----
SPORTS_CENTER_SERVICE_URL=http://sports-center-service:8006 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=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_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/sports_center_db
SPORTS_CENTER_SERVICE_NAME=sports-center-service SPORTS_CENTER_SERVICE_NAME=sports-center-service
# ---- OTP / Payamak SMS (Core Service) ---- # ---- Delivery Service (Phase 10.0 / Torbat Driver) ----
# ApiKey را در پارامتر password ارسال کنید (طبق مستندات ملی‌پیامک) DELIVERY_SERVICE_URL=http://delivery-service:8007
PAYAMAK_USERNAME=9155105404 DELIVERY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/delivery_db
PAYAMAK_FROM=9982004961 DELIVERY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/delivery_db
PAYAMAK_FROM_NUMBER=9982004961 DELIVERY_SERVICE_NAME=delivery-service
# ApiKey را در پارامتر password ارسال کنید (مستندات ملی‌پیامک)
PAYAMAK_API_KEY=967ceb37-eeef-429c-b6c5-b60a15306e4f # ---- Healthcare Service (Phase 13.0) ----
PAYAMAK_APIKEY=967ceb37-eeef-429c-b6c5-b60a15306e4f HEALTHCARE_SERVICE_URL=http://healthcare-service:8010
PAYAMAK_PASSWORD=967ceb37-eeef-429c-b6c5-b60a15306e4f HEALTHCARE_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/healthcare_db
PAYAMAK_BODY_ID=245189 HEALTHCARE_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/healthcare_db
# آدرس رسمی: https://api.payamak-panel.com/post/send.asmx/SendByBaseNumber HEALTHCARE_SERVICE_NAME=healthcare-service
JWT_SECRET=change-me-jwt-secret
OTP_EXPIRE_SECONDS=120 # ---- Beauty Business Service (Phase 14.0 / Torbat Beauty) ----
BEAUTY_BUSINESS_SERVICE_URL=http://beauty-business-service:8011
# شماره‌های ادمین پلتفرم (OTP) — با کاما جدا شوند BEAUTY_BUSINESS_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/beauty_business_db
PLATFORM_ADMIN_MOBILES=09155105404 BEAUTY_BUSINESS_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/beauty_business_db
BEAUTY_BUSINESS_SERVICE_NAME=beauty-business-service
# ---- Frontend (Next.js) ----
FRONTEND_PORT=3000 # ---- Hospitality Service (Phase 12.0 / Torbat Food) ----
NEXT_PUBLIC_PLATFORM_BASE_DOMAIN=torbatyar.ir HOSPITALITY_SERVICE_URL=http://hospitality-service:8009
NEXT_PUBLIC_BACKEND_URL=https://api.torbatyar.ir HOSPITALITY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/hospitality_db
NEXT_PUBLIC_API_BASE_URL=https://api.torbatyar.ir HOSPITALITY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/hospitality_db
NEXT_PUBLIC_IDENTITY_API_URL=https://identity.torbatyar.ir HOSPITALITY_SERVICE_NAME=hospitality-service
NEXT_PUBLIC_KEYCLOAK_URL=https://auth.torbatyar.ir
NEXT_PUBLIC_KEYCLOAK_REALM=superapp EXPERIENCE_SERVICE_URL=http://experience-service:8008
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend EXPERIENCE_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/experience_db
NEXT_PUBLIC_ACCOUNTING_API_URL=http://localhost:8002 EXPERIENCE_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/experience_db
EXPERIENCE_SERVICE_NAME=experience-service
INTERNAL_TOKEN_SECRET=change-me-internal-secret
# ---- OTP / Payamak SMS (Core Service) ----
# ---- Keycloak Bootstrap ---- # ApiKey را در پارامتر password ارسال کنید (طبق مستندات ملی‌پیامک)
KEYCLOAK_ADMIN=admin PAYAMAK_USERNAME=9155105404
KEYCLOAK_ADMIN_PASSWORD=admin 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

View File

@ -1,6 +1,6 @@
# Loyalty Service — Enterprise Loyalty Platform # 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.07.5 engines + immutable wallet ledger). Independent shared service — **not** part of CRM.
Reusable by Restaurant, Marketplace, Ecommerce, Academy, Booking, Healthcare, Reusable by Restaurant, Marketplace, Ecommerce, Academy, Booking, Healthcare,
Salon, Gym, and future modules via API + Events only. 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 | | LoyaltyProgram, MembershipTier, Member + lifecycle | CRM sales entities |
| MembershipLifecycleEvent history | Accounting / Posting Engine | | MembershipLifecycleEvent history | Accounting / Posting Engine |
| PointAccount shell (no mutable balance) | Notification delivery | | PointAccount + immutable `PointLedgerEntry` | Notification delivery |
| Reward catalog shell | Analytics store (later) | | Reward catalog + `RewardRedemption` lifecycle | Analytics store (later) |
| Campaign shell (versioned rules later) | Identity / Wallet UI / Gift card engine (later phases) | | Campaign engine + applications | Identity / Gift card UI (7.7+) |
| Referral programs / codes / attributions | Payment gateway settlement |
| WalletAccount + immutable `WalletLedgerEntry` | |
| Loyalty audit trail | | | Loyalty audit trail | |
## Boundaries ## Boundaries
- Independent database: `loyalty_db` (ADR-001, ADR-011) - Independent database: `loyalty_db` (ADR-001, ADR-011)
- Row-level tenancy via `tenant_id` from request context (ADR-003) - Row-level tenancy via `tenant_id` from request context (ADR-003)
- Membership lifecycle transitions validated in `MembershipEngineService` - Point and wallet balances come **only** from immutable ledgers — never mutate a balance column
- Program soft-delete rejected while blocking members exist
- Direct balance modification is prohibited — ledger in Phase 7.2
- Soft delete releases unique keys so codes can be reused; audit via `/api/v1/audit` - Soft delete releases unique keys so codes can be reused; audit via `/api/v1/audit`
- Inter-service communication: REST + Events only (transactional outbox) - Inter-service communication: REST + Events only (transactional outbox)
- Platform providers are **contracts only** under `app/providers/` - Platform providers are **contracts only** under `app/providers/`
- Route permissions enforced (`loyalty.*`); invalid `X-Tenant-ID` rejected - 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 Prefix
`/api/v1` on port **8004** `/api/v1` on port **8004**
@ -54,15 +37,16 @@ alembic/versions/0001_initial.py
| Programs | `/api/v1/programs` | | Programs | `/api/v1/programs` |
| Tiers | `/api/v1/tiers` | | Tiers | `/api/v1/tiers` |
| Members | `/api/v1/members` | | Members | `/api/v1/members` |
| Point accounts | `/api/v1/point-accounts` | | Point accounts | `/api/v1/point-accounts` (+ ledger ops) |
| Rewards | `/api/v1/rewards` | | Rewards / Redemptions | `/api/v1/rewards`, `/api/v1/redemptions` |
| Campaigns | `/api/v1/campaigns` | | Campaigns | `/api/v1/campaigns` |
| Referrals | `/api/v1/referral-programs`, `/referral-codes`, `/referrals` |
| Wallets | `/api/v1/wallets` (+ `/balance`, `/ledger`, `/credit`, `/debit`, `/adjust`, `/transfer`) |
| Health | `/health` | | Health | `/health` |
## Permissions ## Permissions
`loyalty.*` including `loyalty.programs.*`, `loyalty.tiers.*`, `loyalty.members.*`, `loyalty.*` including points, rewards, campaigns, referrals, wallets, audit trees.
`loyalty.point_accounts.*`, `loyalty.rewards.*`, `loyalty.campaigns.*`, `loyalty.audit.*`
## Run locally ## Run locally
@ -75,7 +59,8 @@ uvicorn app.main:app --port 8004 --reload
## Related Documents ## 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) - [Module Registry](../../../docs/module-registry.md#loyalty)
- [ADR-011](../../../docs/architecture/adr/ADR-011.md) - [ADR-011](../../../docs/architecture/adr/ADR-011.md)
- [Architecture](../../../docs/architecture/architecture.md)

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -1 +1 @@
__version__ = "0.7.1.0" __version__ = "0.7.6.0"

View File

@ -3,11 +3,17 @@ from fastapi import APIRouter
from app.api.v1 import ( from app.api.v1 import (
audit, audit,
campaigns, campaigns,
ledger,
members, members,
point_accounts, point_accounts,
programs, programs,
redemptions,
referral_codes,
referral_programs,
referrals,
rewards, rewards,
tiers, tiers,
wallets,
) )
api_router = APIRouter() api_router = APIRouter()
@ -17,6 +23,18 @@ api_router.include_router(members.router, prefix="/members", tags=["members"])
api_router.include_router( api_router.include_router(
point_accounts.router, prefix="/point-accounts", tags=["point-accounts"] 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(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(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"]) api_router.include_router(audit.router, prefix="/audit", tags=["audit"])

View File

@ -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 __future__ import annotations
from uuid import UUID 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.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions from app.api.permissions import require_permissions
from app.permissions.definitions import ( from app.permissions.definitions import (
LOYALTY_CAMPAIGNS_ACTIVATE,
LOYALTY_CAMPAIGNS_APPLICATIONS_VIEW,
LOYALTY_CAMPAIGNS_APPLY,
LOYALTY_CAMPAIGNS_CREATE, LOYALTY_CAMPAIGNS_CREATE,
LOYALTY_CAMPAIGNS_DELETE, LOYALTY_CAMPAIGNS_DELETE,
LOYALTY_CAMPAIGNS_EXPIRE,
LOYALTY_CAMPAIGNS_PAUSE,
LOYALTY_CAMPAIGNS_RESUME,
LOYALTY_CAMPAIGNS_RULES_UPDATE,
LOYALTY_CAMPAIGNS_SCHEDULE,
LOYALTY_CAMPAIGNS_UPDATE, LOYALTY_CAMPAIGNS_UPDATE,
LOYALTY_CAMPAIGNS_VIEW, LOYALTY_CAMPAIGNS_VIEW,
) )
from app.schemas.campaigns import (
ApplyCampaignRequest,
CampaignApplicationPage,
CampaignApplicationRead,
CampaignRulesUpdateRequest,
)
from app.schemas.foundation import CampaignCreate, CampaignRead, CampaignUpdate from app.schemas.foundation import CampaignCreate, CampaignRead, CampaignUpdate
from app.services.campaign_engine import CampaignEngineService
from app.services.foundation import CampaignService from app.services.foundation import CampaignService
from shared.pagination import PaginationParams from shared.pagination import PaginationParams
from shared.security import CurrentUser from shared.security import CurrentUser
@ -77,3 +92,103 @@ async def soft_delete_campaign(
return await CampaignService(db).soft_delete( return await CampaignService(db).soft_delete(
tenant_id, campaign_id, actor=user 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
)

View File

@ -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
)

View File

@ -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
)

View File

@ -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)

View File

@ -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
)

View File

@ -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
)

View File

@ -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 __future__ import annotations
from uuid import UUID 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.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions from app.api.permissions import require_permissions
from app.permissions.definitions import ( from app.permissions.definitions import (
LOYALTY_REDEMPTIONS_VIEW,
LOYALTY_REWARDS_CREATE, LOYALTY_REWARDS_CREATE,
LOYALTY_REWARDS_DELETE, LOYALTY_REWARDS_DELETE,
LOYALTY_REWARDS_REDEEM,
LOYALTY_REWARDS_UPDATE, LOYALTY_REWARDS_UPDATE,
LOYALTY_REWARDS_VIEW, LOYALTY_REWARDS_VIEW,
) )
from app.schemas.foundation import RewardCreate, RewardRead, RewardUpdate 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.foundation import RewardService
from app.services.rewards_engine import RewardsEngineService
from shared.pagination import PaginationParams from shared.pagination import PaginationParams
from shared.security import CurrentUser from shared.security import CurrentUser
@ -73,3 +77,37 @@ async def soft_delete_reward(
user: CurrentUser = Depends(require_permissions(LOYALTY_REWARDS_DELETE)), user: CurrentUser = Depends(require_permissions(LOYALTY_REWARDS_DELETE)),
): ):
return await RewardService(db).soft_delete(tenant_id, reward_id, actor=user) 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
)

View File

@ -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)

View File

@ -25,9 +25,38 @@ class LoyaltyEventType(str, enum.Enum):
POINT_ACCOUNT_OPENED = "loyalty.point_account.opened" POINT_ACCOUNT_OPENED = "loyalty.point_account.opened"
POINT_ACCOUNT_UPDATED = "loyalty.point_account.updated" POINT_ACCOUNT_UPDATED = "loyalty.point_account.updated"
POINT_ACCOUNT_DELETED = "loyalty.point_account.deleted" 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_CREATED = "loyalty.reward.created"
REWARD_UPDATED = "loyalty.reward.updated" REWARD_UPDATED = "loyalty.reward.updated"
REWARD_DELETED = "loyalty.reward.deleted" 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_CREATED = "loyalty.campaign.created"
CAMPAIGN_UPDATED = "loyalty.campaign.updated" CAMPAIGN_UPDATED = "loyalty.campaign.updated"
CAMPAIGN_DELETED = "loyalty.campaign.deleted" 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"

View File

@ -9,4 +9,13 @@ from app.models.foundation import ( # noqa: F401
PointAccount, PointAccount,
Reward, Reward,
) )
from app.models.ledger import PointLedgerEntry # noqa: F401
from app.models.outbox import OutboxEvent # 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

View File

@ -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",
),
)

View File

@ -282,6 +282,9 @@ class Reward(
points_cost: Mapped[int] = mapped_column(Integer, default=0, nullable=False) points_cost: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
eligibility_rules: Mapped[dict | None] = mapped_column(JSON, nullable=True) eligibility_rules: Mapped[dict | None] = mapped_column(JSON, nullable=True)
metadata_json: 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__ = ( __table_args__ = (
UniqueConstraint("tenant_id", "program_id", "code", name="uq_rewards_tenant_code"), UniqueConstraint("tenant_id", "program_id", "code", name="uq_rewards_tenant_code"),

View File

@ -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"),
)

View File

@ -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 referrerreferee 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"),
)

View File

@ -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"),
)

View File

@ -73,6 +73,13 @@ class PointAccountStatus(str, enum.Enum):
CLOSED = "closed" CLOSED = "closed"
class LedgerEntryType(str, enum.Enum):
EARN = "earn"
REDEEM = "redeem"
ADJUST = "adjust"
EXPIRE = "expire"
class RewardType(str, enum.Enum): class RewardType(str, enum.Enum):
COUPON = "coupon" COUPON = "coupon"
DISCOUNT = "discount" DISCOUNT = "discount"
@ -98,6 +105,50 @@ class CampaignStatus(str, enum.Enum):
ARCHIVED = "archived" 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): class AuditAction(str, enum.Enum):
CREATE = "create" CREATE = "create"
UPDATE = "update" UPDATE = "update"
@ -113,3 +164,25 @@ class AuditAction(str, enum.Enum):
TRANSFER = "transfer" TRANSFER = "transfer"
CANCEL = "cancel" CANCEL = "cancel"
EXPIRE = "expire" 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"

View File

@ -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"),
)

View File

@ -36,17 +36,64 @@ LOYALTY_POINT_ACCOUNTS_CREATE = "loyalty.point_accounts.create"
LOYALTY_POINT_ACCOUNTS_UPDATE = "loyalty.point_accounts.update" LOYALTY_POINT_ACCOUNTS_UPDATE = "loyalty.point_accounts.update"
LOYALTY_POINT_ACCOUNTS_MANAGE = "loyalty.point_accounts.manage" 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_VIEW = "loyalty.rewards.view"
LOYALTY_REWARDS_CREATE = "loyalty.rewards.create" LOYALTY_REWARDS_CREATE = "loyalty.rewards.create"
LOYALTY_REWARDS_UPDATE = "loyalty.rewards.update" LOYALTY_REWARDS_UPDATE = "loyalty.rewards.update"
LOYALTY_REWARDS_DELETE = "loyalty.rewards.delete" LOYALTY_REWARDS_DELETE = "loyalty.rewards.delete"
LOYALTY_REWARDS_MANAGE = "loyalty.rewards.manage" 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_VIEW = "loyalty.campaigns.view"
LOYALTY_CAMPAIGNS_CREATE = "loyalty.campaigns.create" LOYALTY_CAMPAIGNS_CREATE = "loyalty.campaigns.create"
LOYALTY_CAMPAIGNS_UPDATE = "loyalty.campaigns.update" LOYALTY_CAMPAIGNS_UPDATE = "loyalty.campaigns.update"
LOYALTY_CAMPAIGNS_DELETE = "loyalty.campaigns.delete" LOYALTY_CAMPAIGNS_DELETE = "loyalty.campaigns.delete"
LOYALTY_CAMPAIGNS_MANAGE = "loyalty.campaigns.manage" 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" LOYALTY_AUDIT_VIEW = "loyalty.audit.view"
@ -81,16 +128,57 @@ ALL_PERMISSIONS: list[str] = [
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_EARN,
LOYALTY_POINTS_REDEEM,
LOYALTY_POINTS_ADJUST,
LOYALTY_POINTS_EXPIRE,
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_FULFILL,
LOYALTY_REWARDS_CANCEL,
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_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, LOYALTY_AUDIT_VIEW,
] ]
@ -100,7 +188,13 @@ PERMISSION_PREFIXES: tuple[str, ...] = (
"loyalty.tiers.", "loyalty.tiers.",
"loyalty.members.", "loyalty.members.",
"loyalty.point_accounts.", "loyalty.point_accounts.",
"loyalty.points.",
"loyalty.rewards.", "loyalty.rewards.",
"loyalty.redemptions.",
"loyalty.campaigns.", "loyalty.campaigns.",
"loyalty.referral_programs.",
"loyalty.referral_codes.",
"loyalty.referrals.",
"loyalty.wallets.",
"loyalty.audit.", "loyalty.audit.",
) )

View File

@ -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())

View File

@ -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())

View File

@ -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())

View File

@ -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)

View File

@ -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())

View File

@ -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

View File

@ -285,6 +285,8 @@ class RewardCreate(BaseModel):
points_cost: int = Field(default=0, ge=0) points_cost: int = Field(default=0, ge=0)
eligibility_rules: dict | None = None eligibility_rules: dict | None = None
metadata_json: 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): class RewardUpdate(BaseModel):
@ -297,6 +299,8 @@ class RewardUpdate(BaseModel):
points_cost: int | None = Field(default=None, ge=0) points_cost: int | None = Field(default=None, ge=0)
eligibility_rules: dict | None = None eligibility_rules: dict | None = None
metadata_json: 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): class RewardRead(ORMBase):
@ -311,6 +315,9 @@ class RewardRead(ORMBase):
points_cost: int points_cost: int
eligibility_rules: dict | None eligibility_rules: dict | None
metadata_json: dict | None metadata_json: dict | None
stock_limit: int | None
redeemed_count: int
max_per_member: int | None
is_deleted: bool is_deleted: bool
created_by: str | None created_by: str | None
updated_by: str | None updated_by: str | None

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -20,7 +20,13 @@ from app.models.foundation import (
PointAccount, PointAccount,
Reward, 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 ( from app.repositories.foundation import (
CampaignRepository, CampaignRepository,
LoyaltyProgramRepository, LoyaltyProgramRepository,
@ -992,6 +998,8 @@ class RewardService:
points_cost=validate_non_negative_int(body.points_cost, field="points_cost"), points_cost=validate_non_negative_int(body.points_cost, field="points_cost"),
eligibility_rules=body.eligibility_rules, eligibility_rules=body.eligibility_rules,
metadata_json=body.metadata_json, metadata_json=body.metadata_json,
stock_limit=body.stock_limit,
max_per_member=body.max_per_member,
created_by=actor_id, created_by=actor_id,
updated_by=actor_id, updated_by=actor_id,
) )
@ -1237,6 +1245,12 @@ class CampaignService:
actor: CurrentUser | None = None, actor: CurrentUser | None = None,
) -> Campaign: ) -> Campaign:
entity = await self.get(tenant_id, campaign_id) 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) entity.code = release_unique_token(entity.code, entity.id)
await self.repo.soft_delete( await self.repo.soft_delete(
entity, deleted_by=actor.user_id if actor else None entity, deleted_by=actor.user_id if actor else None

View File

@ -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,
)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -15,7 +15,7 @@ async def test_health(client):
body = resp.json() body = resp.json()
assert body["service"] == "loyalty-service" assert body["service"] == "loyalty-service"
assert body["status"] == "ok" assert body["status"] == "ok"
assert body["version"] == "0.7.1.0" assert body["version"] == "0.7.6.0"
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@ -12,9 +12,9 @@ def test_alembic_revisions_exist():
scripts = ScriptDirectory.from_config(cfg) scripts = ScriptDirectory.from_config(cfg)
revisions = list(scripts.walk_revisions()) revisions = list(scripts.walk_revisions())
assert 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[-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 assert revisions[-1].down_revision is None
@ -32,6 +32,14 @@ def test_migration_upgrade_creates_metadata_tables(db_setup):
"loyalty_audit_logs", "loyalty_audit_logs",
"outbox_events", "outbox_events",
"membership_lifecycle_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 expected.issubset(set(Base.metadata.tables.keys()))
assert "ix_members_tenant_tier" in { assert "ix_members_tenant_tier" in {

View File

@ -16,6 +16,7 @@ def test_required_permission_trees_present():
"loyalty.members.", "loyalty.members.",
"loyalty.point_accounts.", "loyalty.point_accounts.",
"loyalty.rewards.", "loyalty.rewards.",
"loyalty.redemptions.",
"loyalty.campaigns.", "loyalty.campaigns.",
"loyalty.audit.", "loyalty.audit.",
] ]
@ -29,6 +30,7 @@ def test_permission_prefixes_documented():
assert "loyalty.members." in PERMISSION_PREFIXES assert "loyalty.members." in PERMISSION_PREFIXES
assert "loyalty.point_accounts." in PERMISSION_PREFIXES assert "loyalty.point_accounts." in PERMISSION_PREFIXES
assert "loyalty.rewards." in PERMISSION_PREFIXES assert "loyalty.rewards." in PERMISSION_PREFIXES
assert "loyalty.redemptions." in PERMISSION_PREFIXES
assert "loyalty.campaigns." in PERMISSION_PREFIXES assert "loyalty.campaigns." in PERMISSION_PREFIXES

View File

@ -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

View File

@ -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"]

View File

@ -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"]

View File

@ -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"

View File

@ -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())

View File

@ -26,6 +26,34 @@ from app.validators.membership import (
target_status_for, target_status_for,
validate_reason, 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__ = [ __all__ = [
"validate_non_empty", "validate_non_empty",
@ -51,4 +79,26 @@ __all__ = [
"validate_reason", "validate_reason",
"DEFAULT_TERM_DAYS", "DEFAULT_TERM_DAYS",
"ACTIVE_MEMBER_STATUSES", "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",
] ]

View File

@ -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

View File

@ -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

View File

@ -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",
)

View File

@ -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",
)

View File

@ -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",
)

View File

@ -312,6 +312,181 @@ services:
networks: networks:
- superapp_net - 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: frontend:
build: build:
context: ./frontend context: ./frontend
@ -330,6 +505,12 @@ services:
NEXT_PUBLIC_PLATFORM_BASE_DOMAIN: ${NEXT_PUBLIC_PLATFORM_BASE_DOMAIN:-torbatyar.ir} 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} NEXT_PUBLIC_ACCOUNTING_API_URL: ${NEXT_PUBLIC_ACCOUNTING_API_URL:-http://localhost:8002}
ACCOUNTING_SERVICE_URL: ${ACCOUNTING_SERVICE_URL:-http://accounting-service: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 ویندوز # polling برای hot-reload پایدار روی volume mount ویندوز
WATCHPACK_POLLING: "true" WATCHPACK_POLLING: "true"
CHOKIDAR_USEPOLLING: "true" CHOKIDAR_USEPOLLING: "true"

View File

@ -5,9 +5,14 @@
# Agents must keep this file aligned with docs/progress.md and docs/roadmap.md. # Agents must keep this file aligned with docs/progress.md and docs/roadmap.md.
schema_version: 1 schema_version: 1
updated: "2026-07-25" updated: "2026-07-26"
# Delivery Platform registration complete; next delivery-10.0 Foundation. # AF-Discovery complete: Enterprise Phase Discovery mandate (ADR-019).
# Loyalty Phase 7.1 complete; 7.2 Point Engine next for Loyalty track. # 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: phases:
- id: core-1 - id: core-1
@ -85,6 +90,64 @@ phases:
- docs/architecture/adr/ADR-013.md - docs/architecture/adr/ADR-013.md
required_services: [] 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 --- # --- Accounting ---
- id: accounting-5.1 - id: accounting-5.1
name: Accounting Foundation name: Accounting Foundation
@ -300,27 +363,142 @@ phases:
- id: loyalty-7.2 - id: loyalty-7.2
name: Point Engine name: Point Engine
area: Loyalty area: Loyalty
status: planned status: complete
dependencies: dependencies:
- loyalty-7.1 - 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: required_documents:
- docs/phases/Loyalty/README.md - docs/phases/Loyalty/README.md
- docs/ai-framework/phase-template.md - docs/ai-framework/phase-template.md
required_services: required_services:
- loyalty - 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 --- # --- SMS / Communication ---
- id: communication-8 - id: communication-8
name: Enterprise Communication Platform (SMS-first) name: Enterprise Communication Platform (SMS-first)
area: SMS 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 status: complete
dependencies: dependencies:
- onboarding-4 - onboarding-4
required_documents: required_documents:
- docs/communication-phase-8.md - 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 - docs/architecture/adr/ADR-012.md
required_services: required_services:
- communication - 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 - id: sms-panel-future
name: Legacy SMS Panel Scaffold Alignment name: Legacy SMS Panel Scaffold Alignment
@ -410,7 +588,7 @@ phases:
name: Coach & Staff Management name: Coach & Staff Management
area: Sports Center area: Sports Center
description: Coaches, trainers, nutritionists, medical staff, reception, managers, working hours, certificates, skills, payroll refs, availability, permissions. description: Coaches, trainers, nutritionists, medical staff, reception, managers, working hours, certificates, skills, payroll refs, availability, permissions.
status: planned status: complete
dependencies: dependencies:
- sports-center-9.2 - sports-center-9.2
required_previous_phase: sports-center-9.2 required_previous_phase: sports-center-9.2
@ -428,7 +606,7 @@ phases:
name: Scheduling & Booking name: Scheduling & Booking
area: Sports Center area: Sports Center
description: Sports classes, sessions, timetables, reservations, capacity, waiting lists, recurring sessions, resource allocation, facilities, equipment reservation, calendar. description: Sports classes, sessions, timetables, reservations, capacity, waiting lists, recurring sessions, resource allocation, facilities, equipment reservation, calendar.
status: planned status: complete
dependencies: dependencies:
- sports-center-9.3 - sports-center-9.3
required_previous_phase: sports-center-9.3 required_previous_phase: sports-center-9.3
@ -445,7 +623,7 @@ phases:
name: Attendance & Access Control name: Attendance & Access Control
area: Sports Center area: Sports Center
description: Attendance, QR/RFID/barcode/face-ready check-in, manual attendance, entry/exit gates, late arrival, absence, attendance history. description: Attendance, QR/RFID/barcode/face-ready check-in, manual attendance, entry/exit gates, late arrival, absence, attendance history.
status: planned status: complete
dependencies: dependencies:
- sports-center-9.4 - sports-center-9.4
required_previous_phase: sports-center-9.4 required_previous_phase: sports-center-9.4
@ -462,7 +640,7 @@ phases:
name: Training Management name: Training Management
area: Sports Center area: Sports Center
description: Training programs, exercises, workout plans, goals, measurements, progress tracking, nutrition plans, body composition, assessments, performance records. description: Training programs, exercises, workout plans, goals, measurements, progress tracking, nutrition plans, body composition, assessments, performance records.
status: planned status: complete
dependencies: dependencies:
- sports-center-9.5 - sports-center-9.5
required_previous_phase: sports-center-9.5 required_previous_phase: sports-center-9.5
@ -479,7 +657,7 @@ phases:
name: Competition & Event Management name: Competition & Event Management
area: Sports Center area: Sports Center
description: Competitions, tournaments, matches, teams, groups, registrations, rankings, results, medals, certificates, judges, schedules. description: Competitions, tournaments, matches, teams, groups, registrations, rankings, results, medals, certificates, judges, schedules.
status: planned status: complete
dependencies: dependencies:
- sports-center-9.6 - sports-center-9.6
required_previous_phase: sports-center-9.6 required_previous_phase: sports-center-9.6
@ -594,7 +772,7 @@ phases:
name: Delivery Platform Foundation name: Delivery Platform Foundation
area: Delivery 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. 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: dependencies:
- delivery-reg - delivery-reg
required_previous_phase: delivery-reg required_previous_phase: delivery-reg
@ -603,6 +781,8 @@ phases:
- docs/phases/Delivery/README.md - docs/phases/Delivery/README.md
- docs/architecture/adr/ADR-015.md - docs/architecture/adr/ADR-015.md
- docs/phase-handover/phase-dp-reg.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/service-template.md
- docs/ai-framework/phase-template.md - docs/ai-framework/phase-template.md
required_services: required_services:
@ -619,7 +799,7 @@ phases:
name: Driver Management name: Driver Management
area: Delivery area: Delivery
description: Drivers, profiles, credential refs, status lifecycle, permissions, events, APIs. description: Drivers, profiles, credential refs, status lifecycle, permissions, events, APIs.
status: planned status: complete
dependencies: dependencies:
- delivery-10.0 - delivery-10.0
required_previous_phase: delivery-10.0 required_previous_phase: delivery-10.0
@ -628,11 +808,15 @@ phases:
- docs/phases/Delivery/README.md - docs/phases/Delivery/README.md
- docs/architecture/adr/ADR-015.md - docs/architecture/adr/ADR-015.md
- docs/ai-framework/module-template.md - docs/ai-framework/module-template.md
- docs/delivery-phase-10-1.md
- docs/phase-handover/phase-10-1.md
required_services: required_services:
- delivery - delivery
completion_criteria: completion_criteria:
- Driver management APIs + validators + tests green - Driver management APIs + validators + tests green
- Lifecycle, credentials/documents, outbox, list filter/sort/search
- No fleet/dispatch/routing engines yet - No fleet/dispatch/routing engines yet
- Quality gates passed; handover complete
- id: delivery-10.2 - id: delivery-10.2
name: Fleet & Vehicle Types name: Fleet & Vehicle Types
@ -800,19 +984,477 @@ phases:
- Module registry version/phase updated; progress marks 10.10 complete - Module registry version/phase updated; progress marks 10.10 complete
- No undocumented public API/event/permission - No undocumented public API/event/permission
# --- Restaurant --- # --- Experience Platform / Torbat Pages (Phase 11.011.10) ---
- id: restaurant-foundation - id: experience-reg
name: Restaurant / Cafe Foundation name: Experience Platform Registration
area: Restaurant area: Experience
description: Documentation-only registration of independent experience service, experience_db, phases 11.011.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.011.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 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: dependencies:
- onboarding-4 - onboarding-4
- ai-framework - ai-framework
required_documents: required_documents:
- docs/hospitality-phase-12-0.md
- docs/phases/Restaurant/README.md - docs/phases/Restaurant/README.md
- docs/ai-framework/service-template.md
required_services: required_services:
- restaurant - hospitality
# --- Marketplace / Ecommerce --- # --- Marketplace / Ecommerce ---
- id: ecommerce-foundation - id: ecommerce-foundation
@ -929,4 +1571,4 @@ phases:
- docs/phases/Future/README.md - docs/phases/Future/README.md
- docs/roadmap.md - docs/roadmap.md
required_services: [] 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.

View File

@ -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

View File

@ -125,22 +125,32 @@ services:
- loyalty.tier.* - loyalty.tier.*
- loyalty.member.* - loyalty.member.*
- loyalty.point_account.* - loyalty.point_account.*
- loyalty.points.*
- loyalty.reward.* - loyalty.reward.*
- loyalty.campaign.* - loyalty.campaign.*
- loyalty.referral.*
- loyalty.wallet.*
health_endpoint: /health health_endpoint: /health
documentation: documentation:
- docs/loyalty-phase-7-0.md - docs/loyalty-phase-7-0.md
- docs/loyalty-phase-7-1.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/phases/Loyalty/README.md
- docs/architecture/adr/ADR-011.md - docs/architecture/adr/ADR-011.md
current_version: 0.7.1.0 current_version: 0.7.6.0
current_phase: loyalty-7.1 current_phase: loyalty-7.6
status: active status: active
api_port: 8004 api_port: 8004
- id: communication - id: communication
name: Enterprise Communication Platform 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 owner: Platform
path: backend/services/communication path: backend/services/communication
database: communication_db database: communication_db
@ -158,8 +168,12 @@ services:
health_endpoint: /health health_endpoint: /health
documentation: documentation:
- docs/communication-phase-8.md - 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 - docs/architecture/adr/ADR-012.md
current_version: 0.8.10.0 current_version: 0.8.10.1
current_phase: communication-8 current_phase: communication-8
status: active status: active
api_port: 8005 api_port: 8005
@ -241,12 +255,17 @@ services:
- docs/sports-center-phase-9-0.md - docs/sports-center-phase-9-0.md
- docs/sports-center-phase-9-1.md - docs/sports-center-phase-9-1.md
- docs/sports-center-phase-9-2.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/phases/SportsCenter/README.md
- docs/architecture/adr/ADR-014.md - docs/architecture/adr/ADR-014.md
- docs/module-registry.md - docs/module-registry.md
current_version: 0.9.2.0 current_version: 0.9.7.0
current_phase: sports-center-9.2 current_phase: sports-center-9.7
current_status: active current_status: active
status: active status: active
future_modules: future_modules:
@ -294,6 +313,12 @@ services:
- loyalty - loyalty
- communication - communication
events: events:
- delivery.organization.*
- delivery.hub.*
- delivery.external_provider.*
- delivery.routing_engine.*
- delivery.configuration.*
- delivery.setting.*
- delivery.driver.* - delivery.driver.*
- delivery.fleet.* - delivery.fleet.*
- delivery.vehicle.* - delivery.vehicle.*
@ -308,25 +333,36 @@ services:
- /health - /health
- /capabilities - /capabilities
- /metrics - /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: internal_apis:
- service-to-service merchant connector and settlement refs (token-gated; not public) - service-to-service merchant connector and settlement refs (token-gated; not public)
tenant_aware: true tenant_aware: true
audit_enabled: true audit_enabled: true
documentation: documentation:
- docs/delivery-roadmap.md - docs/delivery-roadmap.md
- docs/delivery-phase-10-0.md
- docs/delivery-phase-10-1.md
- docs/phases/Delivery/README.md - docs/phases/Delivery/README.md
- docs/phase-handover/phase-dp-reg.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/architecture/adr/ADR-015.md
- docs/module-registry.md - docs/module-registry.md
current_version: 0.0.0 current_version: 0.10.1.0
current_phase: delivery-reg current_phase: delivery-10.1
current_status: registered current_status: active
status: planned status: active
api_port: 8007 api_port: 8007
commercial_product: Torbat Driver commercial_product: Torbat Driver
future_modules: future_modules:
- drivers
- fleet - fleet
- vehicle_types - vehicle_types
- vehicles - vehicles
@ -351,24 +387,300 @@ services:
- ai_hooks - ai_hooks
- external_providers - external_providers
- id: restaurant - id: experience
name: Restaurant / Cafe name: Enterprise Experience Platform
owner: TBD 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.
path: backend/services/restaurant owner: Platform
database: restaurant_db path: backend/services/experience
database: experience_db
api_prefix: /api/v1 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: dependencies:
- core-platform - core-platform
- shared-lib
optional_dependencies:
- file_storage
- crm
- loyalty - loyalty
- communication
- accounting
- sports_center
- delivery
- ai_assistant
- automation
events: 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 health_endpoint: /health
documentation: documentation:
- docs/phases/Restaurant/README.md - docs/phases/Restaurant/README.md
current_version: 0.0.0 - docs/hospitality-phase-12-0.md
current_phase: restaurant-foundation - docs/hospitality-phase-12-1.md
status: scaffolded - 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 - id: ecommerce
name: Ecommerce name: Ecommerce
@ -524,9 +836,11 @@ services:
health_endpoint: /health health_endpoint: /health
documentation: documentation:
- docs/module-registry.md - docs/module-registry.md
- docs/architecture/adr/ADR-016.md
current_version: 0.0.0 current_version: 0.0.0
current_phase: future-services current_phase: future-services
status: scaffolded status: scaffolded
notes: Prefer experience service (ADR-016 / Torbat Pages) for new page/site work. Historical scaffold only.
- id: live_chat - id: live_chat
name: Live Chat name: Live Chat

101
docs/loyalty-phase-7-2.md Normal file
View File

@ -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.57.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)

92
docs/loyalty-phase-7-3.md Normal file
View File

@ -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.57.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)

61
docs/loyalty-phase-7-4.md Normal file
View File

@ -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)

35
docs/loyalty-phase-7-5.md Normal file
View File

@ -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)

100
docs/loyalty-phase-7-6.md Normal file
View File

@ -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)

View File

@ -169,27 +169,27 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
| Name | Enterprise Loyalty Platform | | Name | Enterprise Loyalty Platform |
| Description | Shared loyalty: programs, members, tiers, point accounts, rewards, campaigns; reusable by all business modules | | Description | Shared loyalty: programs, members, tiers, point accounts, rewards, campaigns; reusable by all business modules |
| Owner | Platform | | 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) | | Dependencies | Core entitlement (feature-key gate deferred platform-wide; JWT + tenant header enforced) |
| Internal Dependencies | None — independent of CRM | | Internal Dependencies | None — independent of CRM |
| External Dependencies | Platform providers via contracts only (Notification, Analytics, Customer360, AI, ModuleIntegration, CRM, Communication, FileStorage) | | External Dependencies | Platform providers via contracts only (Notification, Analytics, Customer360, AI, ModuleIntegration, CRM, Communication, FileStorage) |
| Database Ownership | Sole owner | | Database Ownership | Sole owner |
| Database | `loyalty_db` | | Database | `loyalty_db` |
| API Prefix | `/api/v1` (service on port 8004) | | 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.*` | | 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.reward.*`, `loyalty.campaign.*` | | 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 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 | | Provider Dependencies | Contracts only — no implementations |
| AI Dependencies | Optional via `AIProvider` contract only | | 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 | | 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.1 complete (Membership Engine); 7.2 not started | | Current Phase | 7.6 complete (Wallet); 7.7 next |
| Version | 0.7.1.0 | | Version | 0.7.6.0 |
| Version Compatibility | Designed for Core entitlement; runtime feature-check deferred | | 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) | | Tenant Aware | Yes (required) |
| Permission Tree | `loyalty.*` (view/manage inheritance supported) | | Permission Tree | `loyalty.*` (view/manage inheritance supported) |
| Future Plans | Phases 7.27.10 (points ledger, rewards, campaigns, coupon/voucher, wallet/gift, partner, integrations, AI/analytics, production readiness) | | Future Plans | Phases 7.77.10 (gift cards, analytics, partner APIs, final review) |
--- ---
@ -197,10 +197,10 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
| Field | Value | | 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 | | Description | Independent shared messaging: providers, router, SMS, templates, dynamic contacts, queue, delivery tracking, OTP, webhooks, monitoring |
| Owner | Platform | | Owner | Platform |
| Status | Active (Phase 8.08.10 complete) | | Status | **Production Ready (SMS MVP)** — Phase 8.08.10 complete; feature-complete for current scope |
| Dependencies | Core entitlement (optional gate); no other business modules required | | Dependencies | Core entitlement (optional gate); no other business modules required |
| Internal Dependencies | None — independent of CRM / Loyalty / Restaurant | | Internal Dependencies | None — independent of CRM / Loyalty / Restaurant |
| External Dependencies | SMS providers (Payamak, mock); future email/push/social/voice adapters | | 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) | | Event Consumers | Any subscribed business module (via API/events only) |
| Provider Dependencies | Payamak (SMS), Mock; stubs for smtp/fcm/whatsapp/telegram/rubika/voice | | Provider Dependencies | Payamak (SMS), Mock; stubs for smtp/fcm/whatsapp/telegram/rubika/voice |
| AI Dependencies | None | | AI Dependencies | None |
| Documentation | [communication-phase-8.md](communication-phase-8.md), ADR-012, service README | | 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.10 complete | | Current Phase | 8 complete (MVP) |
| Version | 0.8.10.0 | | Version | 0.8.10.1 |
| Version Compatibility | Standalone; Core JWT optional via AUTH_REQUIRED | | Version Compatibility | Standalone; Core JWT optional via AUTH_REQUIRED |
| Migration Version | `0001_initial` | | Migration Version | `0001_initial`, `0002_validation_hardening` |
| Tenant Aware | Yes (required) | | Tenant Aware | Yes (required) |
| Permission Tree | `communication.*` | | 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.digital_memberships` | Digital Memberships | Active | 9.2 |
| `sports_center.waivers` | Waivers | Active | 9.2 | | `sports_center.waivers` | Waivers | Active | 9.2 |
| `sports_center.member_documents` | Member Documents | 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.roles` | Sports Roles | Active (shell) | 9.0 |
| `sports_center.permissions` | Sports Permissions | Active (shell) | 9.0 | | `sports_center.permissions` | Sports Permissions | Active (shell) | 9.0 |
| `sports_center.facilities` | Facilities | 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.events` | Sports Events | Active (shell) | 9.0 |
| `sports_center.settings` | Settings | Active (shell) | 9.0 | | `sports_center.settings` | Settings | Active (shell) | 9.0 |
| `sports_center.audit` | Audit | Active | 9.0 | | `sports_center.audit` | Audit | Active | 9.0 |
| `sports_center.attendance` | Attendance engine | Planned | 9.5 | | `sports_center.attendance` | Attendance engine | Active | 9.5 |
| `sports_center.booking` | Booking | Planned | 9.4 | | `sports_center.booking` | Booking | Active | 9.4 |
| `sports_center.equipment` | Equipment | Planned | 9.4 | | `sports_center.equipment` | Equipment | Active | 9.4 |
| `sports_center.programs` | Programs | Planned | 9.6 | | `sports_center.sessions` | Sports Sessions | Active | 9.4 |
| `sports_center.workouts` | Workouts | Planned | 9.6 | | `sports_center.programs` | Training Programs | Active | 9.6 |
| `sports_center.competitions` | Competitions | Planned | 9.7 | | `sports_center.workouts` | Workouts | Active | 9.6 |
| `sports_center.competitions_events` | Competition events depth | Planned | 9.7 | | `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.nutrition` | Nutrition | Planned | Later slice |
| `sports_center.mobile` | Mobile | Planned | Cross-cutting | | `sports_center.mobile` | Mobile | Planned | Cross-cutting |
| `sports_center.reports` | Reports | Planned | 9.9 | | `sports_center.reports` | Reports | Planned | 9.9 |
@ -336,26 +352,26 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
| Field | Value | | Field | Value |
| --- | --- | | --- | --- |
| Name | Delivery & Fleet Platform (Torbat Driver) | | 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 | | 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 | | Dependencies | Core entitlement; Accounting / CRM / Loyalty / Communication / verticals via API/Events when phases require them |
| Internal Dependencies | Modules owned by this service only | | 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 Ownership | Sole owner |
| Database | `delivery_db` | | 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.*` | | 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 Producers | Delivery |
| Event Consumers | Accounting, Communication, CRM, Loyalty, Restaurant, Marketplace, Clinic, Sports Center, Analytics (future) | | 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) | | 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) | | 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 | DP-Reg complete; next `delivery-10.0` | | Current Phase | 10.1 complete (drivers); next `delivery-10.2` |
| Version | 0.0.0 | | Version | 0.10.1.0 |
| Version Compatibility | Requires Core entitlement; integrations per later phases | | 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) | | Tenant Aware | Yes (required) |
| Permission Tree | `delivery.*` | | Permission Tree | `delivery.*` |
| Future Plans | Phases 10.010.10 per [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | | Future Plans | Phases 10.010.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 | | 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.fleet` | Fleet Management | Planned | 10.2 |
| `delivery.vehicle_types` | Vehicle Types | Planned | 10.2 | | `delivery.vehicle_types` | Vehicle Types | Planned | 10.2 |
| `delivery.vehicles` | Vehicles | 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.dispatcher_panel_apis` | Dispatcher Panel APIs | Planned | 10.9 |
| `delivery.fleet_analytics` | Fleet Analytics | Planned | 10.10 | | `delivery.fleet_analytics` | Fleet Analytics | Planned | 10.10 |
| `delivery.ai_hooks` | AI Ready Hooks | Planned | 10.10 | | `delivery.ai_hooks` | AI Ready Hooks | Planned | 10.10 |
| `delivery.external_providers` | External Providers Ready | Planned | 10.0+ | | `delivery.external_providers` | External Providers Ready | Active (shell) | 10.0 |
| `delivery.audit` | Audit | Planned | 10.0 | | `delivery.audit` | Audit | Active | 10.0 |
| `delivery.configuration` | Configuration | Planned | 10.0 | | `delivery.configuration` | Configuration | Active (shell) | 10.0 |
| Module | Responsibilities | Non-responsibilities | | 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.011.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 ## restaurant
| Field | Value | | Field | Value |
| --- | --- | | --- | --- |
| Name | Restaurant / Cafe | | Name | Restaurant / Cafe (historical) |
| Description | Digital menu, tables, orders, kitchen; loyalty via Loyalty service | | Description | Superseded by Hospitality Platform — see hospitality |
| Owner | TBD | | Owner | Platform |
| Status | Scaffolded | | Status | Superseded |
| Dependencies | Core entitlement, white-label host; Loyalty (API/Events) | | Documentation | [phases/Restaurant](phases/Restaurant/README.md), [hospitality](#hospitality) |
| Internal Dependencies | Optional accounting postings via events | | Current Phase | hospitality-12.0 |
| External Dependencies | Payment provider (future) | | 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 Ownership | Sole owner |
| Database | `restaurant_db` | | Database | `hospitality_db` |
| API Prefix | `/api/v1` | | API Prefix | `/api/v1` (service on port 8009); `/health`, `/capabilities` |
| Permission Prefix | `restaurant.*` | | Permission Prefix | `hospitality.*` |
| Events | `order.*` (planned) | | 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 | Restaurant | | Event Producers | Hospitality |
| Event Consumers | Accounting, notification | | Event Consumers | Accounting, CRM, Loyalty, Communication, Delivery, Experience (subscribed) |
| Provider Dependencies | Payment (planned) | | Provider Dependencies | Contracts only through 12.8 (mock implementations for local dispatch simulation) |
| AI Dependencies | Optional | | AI Dependencies | Optional |
| Documentation | [phases/Restaurant](phases/Restaurant/README.md) | | 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 | Candidate first business module after white-label | | Current Phase | 12.8 complete |
| Version | 0.0.0 | | Version | 0.12.8.0 |
| Version Compatibility | Core + public tenant site | | Version Compatibility | Standalone; Core JWT optional via AUTH_REQUIRED |
| Migration Version | None | | Migration Version | `0009_phase_128_analytics` |
| Tenant Aware | Yes | | Tenant Aware | Yes (required) |
| Permission Tree | `restaurant.*` | | Permission Tree | `hospitality.*` |
| Future Plans | Guest ordering on tenant domain | | 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.013.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.014.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 | | Field | Value |
| --- | --- | | --- | --- |
| Name | Website Builder | | 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 | | Owner | TBD |
| Status | Scaffolded | | Status | Scaffolded (deferred; prefer `experience`) |
| Dependencies | file_storage | | Dependencies | file_storage |
| Internal Dependencies | — | | Internal Dependencies | — |
| External Dependencies | S3 | | External Dependencies | S3 |
| Database Ownership | Sole owner | | Database Ownership | Sole owner (historical scaffold) |
| Database | `website_builder_db` | | Database | `website_builder_db` |
| API Prefix | `/api/v1` | | API Prefix | `/api/v1` |
| Permission Prefix | `website_builder.*` | | Permission Prefix | `website_builder.*` |
@ -518,14 +795,14 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
| Event Consumers | — | | Event Consumers | — |
| Provider Dependencies | S3 | | Provider Dependencies | S3 |
| AI Dependencies | Optional copy assist | | AI Dependencies | Optional copy assist |
| Documentation | service README | | Documentation | service README; [ADR-016](architecture/adr/ADR-016.md) |
| Current Phase | Not started | | Current Phase | Not started — prefer Experience Phases 11.0+ |
| Version | 0.0.0 | | Version | 0.0.0 |
| Version Compatibility | Core | | Version Compatibility | Core |
| Migration Version | None | | Migration Version | None |
| Tenant Aware | Yes | | Tenant Aware | Yes |
| Permission Tree | `website_builder.*` | | 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) - [Provider Registry](provider-registry.md)
- [Roadmap](roadmap.md) - [Roadmap](roadmap.md)
- [Architecture Overview](architecture/architecture.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) - [Phase Manifest](ai-framework/phase-manifest.yaml)
- [Service Manifest](ai-framework/service-manifest.yaml) - [Service Manifest](ai-framework/service-manifest.yaml)
- [ADR-013](architecture/adr/ADR-013.md) - [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) - [Sports Center Roadmap](sports-center-roadmap.md)
- [ADR-014](architecture/adr/ADR-014.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)

View File

@ -1,40 +1,63 @@
# Next Steps # 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 ### 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 ### Scope
1. Immutable point ledger as sole balance source (no mutable balance column) 1. Gift card issue / activate / redeem / void (or equivalent lifecycle)
2. Earn / redeem / adjust / expire ledger entries with audit + outbox events 2. Keep ledger immutability (wallet and/or dedicated gift-card ledger)
3. PointAccount remains a shell linked to member/program — balances derived from ledger 3. Permissions + events; do **not** build analytics/partner APIs yet
4. Do **not** implement rewards redemption engine (7.3) or campaign engine (7.4)
### Exit Criteria ### Exit Criteria
- [ ] Ledger APIs + validators + tests green - [ ] Gift card APIs + validators + tests green
- [ ] Docs: `docs/loyalty-phase-7-2.md` + progress/registry + handover - [ ] Docs + progress/registry + handover + service snapshot
- [ ] Quality gates + phase handover per AI framework - [ ] Quality gates per AI framework
### After This Milestone ### After This Milestone
Phase 7.3 — Rewards & Redemption. Phase 7.8 — Analytics.
---
## Recently Completed: Healthcare Platform (Phases 13.013.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.013.7 complete** | Post-13.7 enhancements (unregistered) |
| [Beauty Business BB-Reg](beauty-business-roadmap.md) | **Phases 14.014.7 complete** | Post-14.7 enhancements (unregistered) |
## Related Documents ## Related Documents
- [Progress](progress.md) - [Progress](progress.md)
- [Phase Handover 7.1](phase-handover/phase-7-1.md) - [Phase Handover 7.6](phase-handover/phase-7-6.md)
- [Loyalty Phase 7.1](loyalty-phase-7-1.md) - [Phase Handover 14.7](phase-handover/phase-14-7.md)
- [Loyalty Phase 7.0](loyalty-phase-7-0.md) - [Phase Handover 13.7](phase-handover/phase-13-7.md)
- [Phases / Loyalty](phases/Loyalty/README.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) - [AI Development Framework](ai-framework/README.md)
- [ADR-011](architecture/adr/ADR-011.md)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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.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.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.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 | Planned | | 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 | Planned | | 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 | Planned | | 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 | Planned | | 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.7 | Gift Card Platform | Planned |
| 7.8 | Analytics | Planned | | 7.8 | Analytics | Planned |
| 7.9 | Public / Partner / Webhook APIs | Planned | | 7.9 | Public / Partner / Webhook APIs | Planned |

File diff suppressed because it is too large Load Diff

View File

@ -58,12 +58,17 @@ Base: Loyalty service port `8004` (`/api/v1`) — public host `loyalty.torbatyar
| Programs | `/api/v1/programs` (+ soft delete) | | Programs | `/api/v1/programs` (+ soft delete) |
| Tiers | `/api/v1/tiers` (+ soft delete) | | Tiers | `/api/v1/tiers` (+ soft delete) |
| Members | `/api/v1/members` (+ `/enroll`, `/activate`, `/renew`, `/freeze`, `/resume`, `/cancel`, `/expire`, `/transfer`, `/lifecycle`, 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) | | Point accounts | `/api/v1/point-accounts` (+ soft delete; `/balance`, `/ledger`, `/earn`, `/redeem`, `/adjust`, `/expire-points`) |
| Rewards | `/api/v1/rewards` (+ soft delete) | | Rewards | `/api/v1/rewards` (+ soft delete; `/redeem`, `/redemptions`) |
| Campaigns | `/api/v1/campaigns` (+ soft delete) | | 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` | | 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 ## Future Services

View File

@ -1,136 +1,180 @@
# Event Catalog # Event Catalog
Canonical event types. Envelope rules: [event-driven-architecture.md](../architecture/event-driven-architecture.md). Canonical event types. Envelope rules: [event-driven-architecture.md](../architecture/event-driven-architecture.md).
## Core Platform ## Core Platform
| event_type | aggregate | Description | | event_type | aggregate | Description |
| --- | --- | --- | | --- | --- | --- |
| `tenant.created` | tenant | New tenant created | | `tenant.created` | tenant | New tenant created |
| `tenant.suspended` | tenant | Tenant suspended | | `tenant.suspended` | tenant | Tenant suspended |
| `tenant.activated` | tenant | Tenant activated | | `tenant.activated` | tenant | Tenant activated |
| `domain.created` | domain | Domain added | | `domain.created` | domain | Domain added |
| `subscription.created` | subscription | Subscription created | | `subscription.created` | subscription | Subscription created |
| `subscription.updated` | subscription | Subscription changed | | `subscription.updated` | subscription | Subscription changed |
| `feature_access.changed` | feature_access | Entitlement override/plan effect changed | | `feature_access.changed` | feature_access | Entitlement override/plan effect changed |
## Identity & Access ## Identity & Access
| event_type | Description | | event_type | Description |
| --- | --- | | --- | --- |
| `user.registered` | New user registered | | `user.registered` | New user registered |
| `tenant_member.added` | Member added (identity layer) | | `tenant_member.added` | Member added (identity layer) |
| `tenant_member.removed` | Member removed (identity layer) | | `tenant_member.removed` | Member removed (identity layer) |
## Accounting (Phases 5.15.6) ## Accounting (Phases 5.15.6)
| event_type | Description | | event_type | Description |
| --- | --- | | --- | --- |
| `voucher.created` | Voucher drafted | | `voucher.created` | Voucher drafted |
| `voucher.validated` | Voucher passed validation | | `voucher.validated` | Voucher passed validation |
| `voucher.posted` | Voucher posted to ledger | | `voucher.posted` | Voucher posted to ledger |
| `voucher.reversed` | Voucher reversed | | `voucher.reversed` | Voucher reversed |
| `journal_entry.created` | Journal entry created via Posting Engine | | `journal_entry.created` | Journal entry created via Posting Engine |
| `posting.completed` | Posting operation succeeded | | `posting.completed` | Posting operation succeeded |
| `ledger.updated` | General ledger updated | | `ledger.updated` | General ledger updated |
| `trial_balance.generated` | Trial balance snapshot created | | `trial_balance.generated` | Trial balance snapshot created |
| `fiscal_period.locked` | Fiscal period locked | | `fiscal_period.locked` | Fiscal period locked |
| `cash.received` | Cash receipt recorded | | `cash.received` | Cash receipt recorded |
| `settlement.completed` | AR/AP settlement completed | | `settlement.completed` | AR/AP settlement completed |
| `sales_invoice.posted` | Sales invoice accounting posted | | `sales_invoice.posted` | Sales invoice accounting posted |
| `revenue.recognized` | Revenue recognition recorded | | `revenue.recognized` | Revenue recognition recorded |
Full list: `backend/services/accounting/app/events/types.py` Full list: `backend/services/accounting/app/events/types.py`
## CRM (Phases 6.06.1) ## CRM (Phases 6.06.1)
| event_type | Description | | event_type | Description |
| --- | --- | | --- | --- |
| `crm.lead.created` | Lead created | | `crm.lead.created` | Lead created |
| `crm.lead.updated` | Lead updated | | `crm.lead.updated` | Lead updated |
| `crm.contact.created` | Contact created | | `crm.contact.created` | Contact created |
| `crm.organization.created` | Organization created | | `crm.organization.created` | Organization created |
| `crm.opportunity.created` | Opportunity created | | `crm.opportunity.created` | Opportunity created |
| `crm.opportunity.updated` | Opportunity updated | | `crm.opportunity.updated` | Opportunity updated |
| `crm.opportunity.won` | Opportunity marked won | | `crm.opportunity.won` | Opportunity marked won |
| `crm.opportunity.lost` | Opportunity marked lost | | `crm.opportunity.lost` | Opportunity marked lost |
| `crm.pipeline.changed` | Pipeline updated | | `crm.pipeline.changed` | Pipeline updated |
| `crm.stage.changed` | Opportunity stage changed | | `crm.stage.changed` | Opportunity stage changed |
| `crm.playbook.assigned` | Sales playbook assigned | | `crm.playbook.assigned` | Sales playbook assigned |
| `crm.playbook.completed` | Playbook assignment completed | | `crm.playbook.completed` | Playbook assignment completed |
| `crm.forecast.updated` | Sales forecast computed/updated | | `crm.forecast.updated` | Sales forecast computed/updated |
| `crm.activity.completed` | Sales activity completed | | `crm.activity.completed` | Sales activity completed |
| `crm.task.completed` | Sales task completed | | `crm.task.completed` | Sales task completed |
| `crm.meeting.finished` | Meeting finished | | `crm.meeting.finished` | Meeting finished |
| `crm.call.logged` | Call logged against opportunity | | `crm.call.logged` | Call logged against opportunity |
| `crm.timeline.updated` | Sales timeline entry appended | | `crm.timeline.updated` | Sales timeline entry appended |
| `crm.comment.created` | Collaboration comment created | | `crm.comment.created` | Collaboration comment created |
| `crm.mention.created` | User mentioned in CRM collaboration | | `crm.mention.created` | User mentioned in CRM collaboration |
| `crm.quote.created` | Sales quote created | | `crm.quote.created` | Sales quote created |
| `crm.note.created` | CRM note created | | `crm.note.created` | CRM note created |
| `crm.attachment.added` | Attachment reference added | | `crm.attachment.added` | Attachment reference added |
Full list: `backend/services/crm/app/events/types.py` Full list: `backend/services/crm/app/events/types.py`
## Loyalty (Phase 7.0) ## Loyalty (Phase 7.0)
| event_type | Description | | event_type | Description |
| --- | --- | | --- | --- |
| `loyalty.program.created` | Loyalty program created | | `loyalty.program.created` | Loyalty program created |
| `loyalty.program.updated` | Loyalty program updated | | `loyalty.program.updated` | Loyalty program updated |
| `loyalty.program.deleted` | Loyalty program soft-deleted | | `loyalty.program.deleted` | Loyalty program soft-deleted |
| `loyalty.tier.created` | Membership tier created | | `loyalty.tier.created` | Membership tier created |
| `loyalty.tier.updated` | Membership tier updated | | `loyalty.tier.updated` | Membership tier updated |
| `loyalty.tier.deleted` | Membership tier soft-deleted | | `loyalty.tier.deleted` | Membership tier soft-deleted |
| `loyalty.member.created` | Member created | | `loyalty.member.created` | Member created |
| `loyalty.member.updated` | Member updated | | `loyalty.member.updated` | Member updated |
| `loyalty.member.enrolled` | Member enrolled / activated | | `loyalty.member.enrolled` | Member enrolled / activated |
| `loyalty.member.activated` | Member activated with term/expiry | | `loyalty.member.activated` | Member activated with term/expiry |
| `loyalty.member.renewed` | Membership renewed | | `loyalty.member.renewed` | Membership renewed |
| `loyalty.member.frozen` | Membership frozen | | `loyalty.member.frozen` | Membership frozen |
| `loyalty.member.resumed` | Membership resumed from freeze | | `loyalty.member.resumed` | Membership resumed from freeze |
| `loyalty.member.cancelled` | Membership cancelled | | `loyalty.member.cancelled` | Membership cancelled |
| `loyalty.member.expired` | Membership expired | | `loyalty.member.expired` | Membership expired |
| `loyalty.member.transferred` | Membership transferred to another program | | `loyalty.member.transferred` | Membership transferred to another program |
| `loyalty.member.deleted` | Member soft-deleted | | `loyalty.member.deleted` | Member soft-deleted |
| `loyalty.point_account.opened` | Point account opened | | `loyalty.point_account.opened` | Point account opened |
| `loyalty.point_account.updated` | Point account updated | | `loyalty.point_account.updated` | Point account updated |
| `loyalty.point_account.deleted` | Point account soft-deleted | | `loyalty.point_account.deleted` | Point account soft-deleted |
| `loyalty.reward.created` | Reward catalog item created | | `loyalty.points.earned` | Points earned on ledger |
| `loyalty.reward.updated` | Reward catalog item updated | | `loyalty.points.redeemed` | Points redeemed on ledger |
| `loyalty.reward.deleted` | Reward soft-deleted | | `loyalty.points.adjusted` | Points adjusted on ledger |
| `loyalty.campaign.created` | Campaign created | | `loyalty.points.expired` | Points expired on ledger |
| `loyalty.campaign.updated` | Campaign updated | | `loyalty.reward.created` | Reward catalog item created |
| `loyalty.campaign.deleted` | Campaign soft-deleted | | `loyalty.reward.updated` | Reward catalog item updated |
| `loyalty.reward.deleted` | Reward soft-deleted |
Full list: `backend/services/loyalty/app/events/types.py` | `loyalty.reward.redeemed` | Reward redeemed (points debited) |
| `loyalty.reward.fulfilled` | Redemption fulfilled |
## Communication (Phase 8) | `loyalty.reward.redemption_cancelled` | Redemption cancelled / refunded |
| `loyalty.campaign.created` | Campaign created |
| event_type | Description | | `loyalty.campaign.updated` | Campaign updated |
| --- | --- | | `loyalty.campaign.deleted` | Campaign soft-deleted |
| `communication.message.queued` | Message enqueued | | `loyalty.campaign.applied` | Campaign applied (points granted) |
| `communication.message.sent` | Accepted by provider | | `loyalty.referral.rewarded` | Referral bonuses granted |
| `communication.message.delivered` | Delivery confirmed | | `loyalty.wallet.opened` | Wallet account opened |
| `communication.message.failed` | Terminal failure | | `loyalty.wallet.credited` | Wallet credited |
| `communication.message.cancelled` | Cancelled | | `loyalty.wallet.debited` | Wallet debited |
| `communication.provider.failover` | Provider failover | | `loyalty.wallet.adjusted` | Wallet adjusted |
| `communication.otp.generated` | OTP issued | | `loyalty.wallet.transferred` | Wallet transfer |
| `communication.otp.verified` | OTP verified | | `loyalty.wallet.closed` | Wallet soft-closed |
| `communication.queue.dead_letter` | Queue DLQ |
| `communication.webhook.received` | Inbound webhook | Full list: `backend/services/loyalty/app/events/types.py`
Full list: `backend/services/communication/app/events/types.py` ## Communication (Phase 8)
## Future (reserved) | event_type | Description |
| Restaurant | `order.placed`, `order.completed` | | --- | --- |
| Ecommerce | `order.placed`, `product.updated` | | `communication.message.queued` | Message enqueued |
| Notification | `notification.delivered` (prefer Communication delivery events) | | `communication.message.sent` | Accepted by provider |
| `communication.message.delivered` | Delivery confirmed |
When adding events: update this catalog, module registry producers/consumers, and contracts in the same phase. | `communication.message.failed` | Terminal failure |
| `communication.message.cancelled` | Cancelled |
## Related Documents | `communication.provider.failover` | Provider failover |
| `communication.otp.generated` | OTP issued |
- [Services Contracts](services-contracts.md) | `communication.otp.verified` | OTP verified |
- [ADR-006](../architecture/adr/ADR-006.md) | `communication.queue.dead_letter` | Queue DLQ |
- [Module Registry](../module-registry.md) | `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 |

View File

@ -16,18 +16,38 @@
3. ~~CRM Core Business Entities~~ (Phase 6.1 done) 3. ~~CRM Core Business Entities~~ (Phase 6.1 done)
4. ~~CRM Enterprise Sales Process Engine~~ (Phase 6.2 done) 4. ~~CRM Enterprise Sales Process Engine~~ (Phase 6.2 done)
5. ~~CRM Enterprise Sales Collaboration~~ (Phase 6.3 done — CRM Core Platform) 5. ~~CRM Enterprise Sales Collaboration~~ (Phase 6.3 done — CRM Core Platform)
8. ~~Loyalty Service Foundation~~ (Phase 7.0 done) 6. ~~Loyalty Service Foundation~~ (Phase 7.0 done)
9. ~~Loyalty Membership Engine~~ (Phase 7.1 done) 7. ~~Loyalty Membership Engine~~ (Phase 7.1 done)
10. Loyalty Point Engine (Phase 7.2) 8. ~~Loyalty Point Engine~~ (Phase 7.2 done)
11. ~~Enterprise Communication Platform~~ (Phase 8.08.10 done) 8a. ~~Loyalty Rewards Engine~~ (Phase 7.3 done)
12. ~~Sports Center Platform Foundation~~ (Phase 9.0 done) 8b. ~~Loyalty Campaign Engine~~ (Phase 7.4 done)
13. ~~Sports Center Membership Catalog~~ (Phase 9.1 done) 8c. ~~Loyalty Referral Engine~~ (Phase 7.5 done)
14. ~~Sports Center Member Management~~ (Phase 9.2 done) 8d. ~~Loyalty Wallet~~ (Phase 7.6 done)
15. Sports Center Coach & Staff Management (Phase 9.3) 8e. Loyalty Gift Card Platform (Phase 7.7)
16. Further CRM / cross-module work only when explicitly scoped 9. ~~Enterprise Communication Platform~~ (Phase 8.08.10 done — **Production Ready SMS MVP**; future channels → [communication-roadmap.md](communication-roadmap.md))
17. Notification service as thin consumer of Communication (or merge later) 10. ~~Sports Center Platform Foundation~~ (Phase 9.0 done)
18. File storage + S3 provider 11. ~~Sports Center Membership Catalog~~ (Phase 9.1 done)
19. Payment gateway for subscriptions and commerce 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.210.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.511.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.013.7)
31. ~~Beauty Business Platform Registration~~ (BB-Reg done — docs only)
32. Beauty Business Platform Foundation through Marketing & Loyalty (Phases 14.014.7)
## Longer Term ## Longer Term
@ -43,15 +63,24 @@
- [Accounting](phases/Accounting/README.md) - [Accounting](phases/Accounting/README.md)
- [CRM](phases/CRM/README.md) - [CRM](phases/CRM/README.md)
- [Loyalty](phases/Loyalty/README.md) - [Loyalty](phases/Loyalty/README.md)
- [Communication Roadmap](communication-roadmap.md)
- [Restaurant](phases/Restaurant/README.md) - [Restaurant](phases/Restaurant/README.md)
- [Sports Center](phases/SportsCenter/README.md) - [Sports Center](phases/SportsCenter/README.md)
- [Sports Center Roadmap](sports-center-roadmap.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) - [Marketplace](phases/Marketplace/README.md)
- [Automation](phases/Automation/README.md) - [Automation](phases/Automation/README.md)
- [AI](phases/AI/README.md) - [AI](phases/AI/README.md)
- [Future](phases/Future/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 ## Related Documents
@ -62,3 +91,9 @@ Implementation of every future phase must follow the [AI Development Framework](
- [Phase Manifest](ai-framework/phase-manifest.yaml) - [Phase Manifest](ai-framework/phase-manifest.yaml)
- [Sports Center Roadmap](sports-center-roadmap.md) - [Sports Center Roadmap](sports-center-roadmap.md)
- [ADR-014](architecture/adr/ADR-014.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)

View File

@ -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"

View File

@ -82,6 +82,7 @@ NEXT_PUBLIC_KEYCLOAK_REALM=superapp
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend
NEXT_PUBLIC_ACCOUNTING_API_URL=https://accounting.torbatyar.ir NEXT_PUBLIC_ACCOUNTING_API_URL=https://accounting.torbatyar.ir
NEXT_PUBLIC_CRM_API_URL=https://crm.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_SERVICE_URL=http://accounting-service:8002
ACCOUNTING_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/accounting_db 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=postgresql+asyncpg://superapp:superapp_password@postgres:5432/loyalty_db
LOYALTY_DATABASE_URL_SYNC=postgresql+psycopg://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 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_SERVER_URL=http://keycloak:8080
KEYCLOAK_REALM=superapp KEYCLOAK_REALM=superapp

View File

@ -33,11 +33,23 @@ upstream torbatyar_loyalty {
server 192.168.10.162:8004; server 192.168.10.162:8004;
keepalive 16; 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 # Force HTTPS for platform hosts
server { server {
listen 192.168.10.156:80; 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/ { location /.well-known/acme-challenge/ {
root /var/www/html; root /var/www/html;
@ -80,8 +92,8 @@ server {
listen 192.168.10.156:443 ssl; listen 192.168.10.156:443 ssl;
server_name torbatyar.ir www.torbatyar.ir *.torbatyar.ir; server_name torbatyar.ir www.torbatyar.ir *.torbatyar.ir;
ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf; include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
@ -104,8 +116,8 @@ server {
listen 192.168.10.156:443 ssl; listen 192.168.10.156:443 ssl;
server_name api.torbatyar.ir; server_name api.torbatyar.ir;
ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf; include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
@ -125,8 +137,8 @@ server {
listen 192.168.10.156:443 ssl; listen 192.168.10.156:443 ssl;
server_name identity.torbatyar.ir; server_name identity.torbatyar.ir;
ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf; include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
@ -146,8 +158,8 @@ server {
listen 192.168.10.156:443 ssl; listen 192.168.10.156:443 ssl;
server_name auth.torbatyar.ir; server_name auth.torbatyar.ir;
ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf; include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
@ -170,8 +182,8 @@ server {
listen 192.168.10.156:443 ssl; listen 192.168.10.156:443 ssl;
server_name accounting.torbatyar.ir; server_name accounting.torbatyar.ir;
ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf; include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
@ -191,8 +203,8 @@ server {
listen 192.168.10.156:443 ssl; listen 192.168.10.156:443 ssl;
server_name crm.torbatyar.ir; server_name crm.torbatyar.ir;
ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf; include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
@ -212,8 +224,8 @@ server {
listen 192.168.10.156:443 ssl; listen 192.168.10.156:443 ssl;
server_name loyalty.torbatyar.ir; server_name loyalty.torbatyar.ir;
ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf; include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
@ -228,3 +240,66 @@ server {
client_max_body_size 50m; 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;
}
}

View File

@ -4,3 +4,6 @@ CREATE DATABASE crm_db;
CREATE DATABASE communication_db; CREATE DATABASE communication_db;
CREATE DATABASE loyalty_db; CREATE DATABASE loyalty_db;
CREATE DATABASE sports_center_db; CREATE DATABASE sports_center_db;
CREATE DATABASE hospitality_db;
CREATE DATABASE experience_db;
CREATE DATABASE healthcare_db;

View File

@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""Deploy Loyalty service (Phases 7.07.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