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:
parent
e140908034
commit
071c484530
29
.env.example
29
.env.example
@ -108,6 +108,35 @@ SPORTS_CENTER_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postg
|
||||
SPORTS_CENTER_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/sports_center_db
|
||||
SPORTS_CENTER_SERVICE_NAME=sports-center-service
|
||||
|
||||
# ---- Delivery Service (Phase 10.0 / Torbat Driver) ----
|
||||
DELIVERY_SERVICE_URL=http://delivery-service:8007
|
||||
DELIVERY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/delivery_db
|
||||
DELIVERY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/delivery_db
|
||||
DELIVERY_SERVICE_NAME=delivery-service
|
||||
|
||||
# ---- Healthcare Service (Phase 13.0) ----
|
||||
HEALTHCARE_SERVICE_URL=http://healthcare-service:8010
|
||||
HEALTHCARE_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/healthcare_db
|
||||
HEALTHCARE_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/healthcare_db
|
||||
HEALTHCARE_SERVICE_NAME=healthcare-service
|
||||
|
||||
# ---- Beauty Business Service (Phase 14.0 / Torbat Beauty) ----
|
||||
BEAUTY_BUSINESS_SERVICE_URL=http://beauty-business-service:8011
|
||||
BEAUTY_BUSINESS_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/beauty_business_db
|
||||
BEAUTY_BUSINESS_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/beauty_business_db
|
||||
BEAUTY_BUSINESS_SERVICE_NAME=beauty-business-service
|
||||
|
||||
# ---- Hospitality Service (Phase 12.0 / Torbat Food) ----
|
||||
HOSPITALITY_SERVICE_URL=http://hospitality-service:8009
|
||||
HOSPITALITY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/hospitality_db
|
||||
HOSPITALITY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/hospitality_db
|
||||
HOSPITALITY_SERVICE_NAME=hospitality-service
|
||||
|
||||
EXPERIENCE_SERVICE_URL=http://experience-service:8008
|
||||
EXPERIENCE_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/experience_db
|
||||
EXPERIENCE_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/experience_db
|
||||
EXPERIENCE_SERVICE_NAME=experience-service
|
||||
|
||||
# ---- OTP / Payamak SMS (Core Service) ----
|
||||
# ApiKey را در پارامتر password ارسال کنید (طبق مستندات ملیپیامک)
|
||||
PAYAMAK_USERNAME=9155105404
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Loyalty Service — Enterprise Loyalty Platform
|
||||
|
||||
> Phase 7.1 Membership Engine complete (foundation 7.0 + lifecycle). Independent shared service — **not** part of CRM.
|
||||
> Phase 7.6 Wallet complete (7.0–7.5 engines + immutable wallet ledger). Independent shared service — **not** part of CRM.
|
||||
|
||||
Reusable by Restaurant, Marketplace, Ecommerce, Academy, Booking, Healthcare,
|
||||
Salon, Gym, and future modules via API + Events only.
|
||||
@ -11,40 +11,23 @@ Salon, Gym, and future modules via API + Events only.
|
||||
| --- | --- |
|
||||
| LoyaltyProgram, MembershipTier, Member + lifecycle | CRM sales entities |
|
||||
| MembershipLifecycleEvent history | Accounting / Posting Engine |
|
||||
| PointAccount shell (no mutable balance) | Notification delivery |
|
||||
| Reward catalog shell | Analytics store (later) |
|
||||
| Campaign shell (versioned rules later) | Identity / Wallet UI / Gift card engine (later phases) |
|
||||
| PointAccount + immutable `PointLedgerEntry` | Notification delivery |
|
||||
| Reward catalog + `RewardRedemption` lifecycle | Analytics store (later) |
|
||||
| Campaign engine + applications | Identity / Gift card UI (7.7+) |
|
||||
| Referral programs / codes / attributions | Payment gateway settlement |
|
||||
| WalletAccount + immutable `WalletLedgerEntry` | |
|
||||
| Loyalty audit trail | |
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Independent database: `loyalty_db` (ADR-001, ADR-011)
|
||||
- Row-level tenancy via `tenant_id` from request context (ADR-003)
|
||||
- Membership lifecycle transitions validated in `MembershipEngineService`
|
||||
- Program soft-delete rejected while blocking members exist
|
||||
- Direct balance modification is prohibited — ledger in Phase 7.2
|
||||
- Point and wallet balances come **only** from immutable ledgers — never mutate a balance column
|
||||
- Soft delete releases unique keys so codes can be reused; audit via `/api/v1/audit`
|
||||
- Inter-service communication: REST + Events only (transactional outbox)
|
||||
- Platform providers are **contracts only** under `app/providers/`
|
||||
- Route permissions enforced (`loyalty.*`); invalid `X-Tenant-ID` rejected
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
app/
|
||||
api/v1/ # Loyalty HTTP APIs only
|
||||
models/ # foundation aggregates
|
||||
repositories/
|
||||
services/
|
||||
validators/
|
||||
schemas/
|
||||
events/
|
||||
permissions/
|
||||
providers/
|
||||
tests/
|
||||
alembic/versions/0001_initial.py
|
||||
```
|
||||
|
||||
## API Prefix
|
||||
|
||||
`/api/v1` on port **8004**
|
||||
@ -54,15 +37,16 @@ alembic/versions/0001_initial.py
|
||||
| Programs | `/api/v1/programs` |
|
||||
| Tiers | `/api/v1/tiers` |
|
||||
| Members | `/api/v1/members` |
|
||||
| Point accounts | `/api/v1/point-accounts` |
|
||||
| Rewards | `/api/v1/rewards` |
|
||||
| Point accounts | `/api/v1/point-accounts` (+ ledger ops) |
|
||||
| Rewards / Redemptions | `/api/v1/rewards`, `/api/v1/redemptions` |
|
||||
| Campaigns | `/api/v1/campaigns` |
|
||||
| Referrals | `/api/v1/referral-programs`, `/referral-codes`, `/referrals` |
|
||||
| Wallets | `/api/v1/wallets` (+ `/balance`, `/ledger`, `/credit`, `/debit`, `/adjust`, `/transfer`) |
|
||||
| Health | `/health` |
|
||||
|
||||
## Permissions
|
||||
|
||||
`loyalty.*` including `loyalty.programs.*`, `loyalty.tiers.*`, `loyalty.members.*`,
|
||||
`loyalty.point_accounts.*`, `loyalty.rewards.*`, `loyalty.campaigns.*`, `loyalty.audit.*`
|
||||
`loyalty.*` including points, rewards, campaigns, referrals, wallets, audit trees.
|
||||
|
||||
## Run locally
|
||||
|
||||
@ -75,7 +59,8 @@ uvicorn app.main:app --port 8004 --reload
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [Phase 7.0](../../../docs/loyalty-phase-7-0.md)
|
||||
- [Phase 7.6](../../../docs/loyalty-phase-7-6.md)
|
||||
- [Handover 7.6](../../../docs/phase-handover/phase-7-6.md)
|
||||
- [Service Snapshot](../../../docs/service-snapshots/loyalty.yaml)
|
||||
- [Module Registry](../../../docs/module-registry.md#loyalty)
|
||||
- [ADR-011](../../../docs/architecture/adr/ADR-011.md)
|
||||
- [Architecture](../../../docs/architecture/architecture.md)
|
||||
|
||||
@ -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")
|
||||
@ -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")
|
||||
@ -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")
|
||||
@ -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")
|
||||
@ -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")
|
||||
@ -1 +1 @@
|
||||
__version__ = "0.7.1.0"
|
||||
__version__ = "0.7.6.0"
|
||||
|
||||
@ -3,11 +3,17 @@ from fastapi import APIRouter
|
||||
from app.api.v1 import (
|
||||
audit,
|
||||
campaigns,
|
||||
ledger,
|
||||
members,
|
||||
point_accounts,
|
||||
programs,
|
||||
redemptions,
|
||||
referral_codes,
|
||||
referral_programs,
|
||||
referrals,
|
||||
rewards,
|
||||
tiers,
|
||||
wallets,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
@ -17,6 +23,18 @@ api_router.include_router(members.router, prefix="/members", tags=["members"])
|
||||
api_router.include_router(
|
||||
point_accounts.router, prefix="/point-accounts", tags=["point-accounts"]
|
||||
)
|
||||
api_router.include_router(ledger.router, prefix="/point-accounts", tags=["ledger"])
|
||||
api_router.include_router(rewards.router, prefix="/rewards", tags=["rewards"])
|
||||
api_router.include_router(
|
||||
redemptions.router, prefix="/redemptions", tags=["redemptions"]
|
||||
)
|
||||
api_router.include_router(campaigns.router, prefix="/campaigns", tags=["campaigns"])
|
||||
api_router.include_router(
|
||||
referral_programs.router, prefix="/referral-programs", tags=["referral-programs"]
|
||||
)
|
||||
api_router.include_router(
|
||||
referral_codes.router, prefix="/referral-codes", tags=["referral-codes"]
|
||||
)
|
||||
api_router.include_router(referrals.router, prefix="/referrals", tags=["referrals"])
|
||||
api_router.include_router(wallets.router, prefix="/wallets", tags=["wallets"])
|
||||
api_router.include_router(audit.router, prefix="/audit", tags=["audit"])
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Campaign APIs — Phase 7.0 shell."""
|
||||
"""Campaign APIs — CRUD (7.0) + lifecycle, rules versioning, and apply (7.4)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
@ -9,12 +9,27 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import (
|
||||
LOYALTY_CAMPAIGNS_ACTIVATE,
|
||||
LOYALTY_CAMPAIGNS_APPLICATIONS_VIEW,
|
||||
LOYALTY_CAMPAIGNS_APPLY,
|
||||
LOYALTY_CAMPAIGNS_CREATE,
|
||||
LOYALTY_CAMPAIGNS_DELETE,
|
||||
LOYALTY_CAMPAIGNS_EXPIRE,
|
||||
LOYALTY_CAMPAIGNS_PAUSE,
|
||||
LOYALTY_CAMPAIGNS_RESUME,
|
||||
LOYALTY_CAMPAIGNS_RULES_UPDATE,
|
||||
LOYALTY_CAMPAIGNS_SCHEDULE,
|
||||
LOYALTY_CAMPAIGNS_UPDATE,
|
||||
LOYALTY_CAMPAIGNS_VIEW,
|
||||
)
|
||||
from app.schemas.campaigns import (
|
||||
ApplyCampaignRequest,
|
||||
CampaignApplicationPage,
|
||||
CampaignApplicationRead,
|
||||
CampaignRulesUpdateRequest,
|
||||
)
|
||||
from app.schemas.foundation import CampaignCreate, CampaignRead, CampaignUpdate
|
||||
from app.services.campaign_engine import CampaignEngineService
|
||||
from app.services.foundation import CampaignService
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
@ -77,3 +92,103 @@ async def soft_delete_campaign(
|
||||
return await CampaignService(db).soft_delete(
|
||||
tenant_id, campaign_id, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/schedule", response_model=CampaignRead)
|
||||
async def schedule_campaign(
|
||||
campaign_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_CAMPAIGNS_SCHEDULE)),
|
||||
):
|
||||
return await CampaignEngineService(db).schedule(tenant_id, campaign_id, actor=user)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/activate", response_model=CampaignRead)
|
||||
async def activate_campaign(
|
||||
campaign_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_CAMPAIGNS_ACTIVATE)),
|
||||
):
|
||||
return await CampaignEngineService(db).activate(tenant_id, campaign_id, actor=user)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/pause", response_model=CampaignRead)
|
||||
async def pause_campaign(
|
||||
campaign_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_CAMPAIGNS_PAUSE)),
|
||||
):
|
||||
return await CampaignEngineService(db).pause(tenant_id, campaign_id, actor=user)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/resume", response_model=CampaignRead)
|
||||
async def resume_campaign(
|
||||
campaign_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_CAMPAIGNS_RESUME)),
|
||||
):
|
||||
return await CampaignEngineService(db).resume(tenant_id, campaign_id, actor=user)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/expire", response_model=CampaignRead)
|
||||
async def expire_campaign(
|
||||
campaign_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_CAMPAIGNS_EXPIRE)),
|
||||
):
|
||||
return await CampaignEngineService(db).expire(tenant_id, campaign_id, actor=user)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/rules", response_model=CampaignRead)
|
||||
async def update_campaign_rules(
|
||||
campaign_id: UUID,
|
||||
body: CampaignRulesUpdateRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_CAMPAIGNS_RULES_UPDATE)),
|
||||
):
|
||||
return await CampaignEngineService(db).update_rules(
|
||||
tenant_id, campaign_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/apply",
|
||||
response_model=CampaignApplicationRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def apply_campaign(
|
||||
campaign_id: UUID,
|
||||
body: ApplyCampaignRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_CAMPAIGNS_APPLY)),
|
||||
):
|
||||
return await CampaignEngineService(db).apply(
|
||||
tenant_id, campaign_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/applications", response_model=CampaignApplicationPage)
|
||||
async def list_campaign_applications(
|
||||
campaign_id: UUID,
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(
|
||||
require_permissions(
|
||||
LOYALTY_CAMPAIGNS_APPLICATIONS_VIEW, LOYALTY_CAMPAIGNS_VIEW
|
||||
)
|
||||
),
|
||||
):
|
||||
rows, total = await CampaignEngineService(db).list_applications(
|
||||
tenant_id, campaign_id, offset=pagination.offset, limit=pagination.page_size
|
||||
)
|
||||
return CampaignApplicationPage(
|
||||
items=rows, total=total, offset=pagination.offset, limit=pagination.page_size
|
||||
)
|
||||
|
||||
115
backend/services/loyalty/app/api/v1/ledger.py
Normal file
115
backend/services/loyalty/app/api/v1/ledger.py
Normal 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
|
||||
)
|
||||
78
backend/services/loyalty/app/api/v1/redemptions.py
Normal file
78
backend/services/loyalty/app/api/v1/redemptions.py
Normal 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
|
||||
)
|
||||
74
backend/services/loyalty/app/api/v1/referral_codes.py
Normal file
74
backend/services/loyalty/app/api/v1/referral_codes.py
Normal 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)
|
||||
83
backend/services/loyalty/app/api/v1/referral_programs.py
Normal file
83
backend/services/loyalty/app/api/v1/referral_programs.py
Normal 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
|
||||
)
|
||||
104
backend/services/loyalty/app/api/v1/referrals.py
Normal file
104
backend/services/loyalty/app/api/v1/referrals.py
Normal 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
|
||||
)
|
||||
@ -1,4 +1,4 @@
|
||||
"""Reward catalog APIs — Phase 7.0 shell."""
|
||||
"""Reward catalog and redemption APIs — Phase 7.0 shell + Phase 7.3 rewards engine."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
@ -9,13 +9,17 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import (
|
||||
LOYALTY_REDEMPTIONS_VIEW,
|
||||
LOYALTY_REWARDS_CREATE,
|
||||
LOYALTY_REWARDS_DELETE,
|
||||
LOYALTY_REWARDS_REDEEM,
|
||||
LOYALTY_REWARDS_UPDATE,
|
||||
LOYALTY_REWARDS_VIEW,
|
||||
)
|
||||
from app.schemas.foundation import RewardCreate, RewardRead, RewardUpdate
|
||||
from app.schemas.rewards import RedeemRequest, RewardRedemptionPage, RewardRedemptionRead
|
||||
from app.services.foundation import RewardService
|
||||
from app.services.rewards_engine import RewardsEngineService
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
@ -73,3 +77,37 @@ async def soft_delete_reward(
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_REWARDS_DELETE)),
|
||||
):
|
||||
return await RewardService(db).soft_delete(tenant_id, reward_id, actor=user)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{reward_id}/redeem",
|
||||
response_model=RewardRedemptionRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def redeem_reward(
|
||||
reward_id: UUID,
|
||||
body: RedeemRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_REWARDS_REDEEM)),
|
||||
):
|
||||
return await RewardsEngineService(db).redeem(tenant_id, reward_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("/{reward_id}/redemptions", response_model=RewardRedemptionPage)
|
||||
async def list_reward_redemptions(
|
||||
reward_id: UUID,
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(LOYALTY_REDEMPTIONS_VIEW)),
|
||||
):
|
||||
rows, total = await RewardsEngineService(db).list(
|
||||
tenant_id,
|
||||
reward_id=reward_id,
|
||||
offset=pagination.offset,
|
||||
limit=pagination.page_size,
|
||||
)
|
||||
return RewardRedemptionPage(
|
||||
items=rows, total=total, offset=pagination.offset, limit=pagination.page_size
|
||||
)
|
||||
|
||||
165
backend/services/loyalty/app/api/v1/wallets.py
Normal file
165
backend/services/loyalty/app/api/v1/wallets.py
Normal 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)
|
||||
@ -25,9 +25,38 @@ class LoyaltyEventType(str, enum.Enum):
|
||||
POINT_ACCOUNT_OPENED = "loyalty.point_account.opened"
|
||||
POINT_ACCOUNT_UPDATED = "loyalty.point_account.updated"
|
||||
POINT_ACCOUNT_DELETED = "loyalty.point_account.deleted"
|
||||
POINTS_EARNED = "loyalty.points.earned"
|
||||
POINTS_REDEEMED = "loyalty.points.redeemed"
|
||||
POINTS_ADJUSTED = "loyalty.points.adjusted"
|
||||
POINTS_EXPIRED = "loyalty.points.expired"
|
||||
REWARD_CREATED = "loyalty.reward.created"
|
||||
REWARD_UPDATED = "loyalty.reward.updated"
|
||||
REWARD_DELETED = "loyalty.reward.deleted"
|
||||
REWARD_REDEEMED = "loyalty.reward.redeemed"
|
||||
REWARD_FULFILLED = "loyalty.reward.fulfilled"
|
||||
REWARD_REDEMPTION_CANCELLED = "loyalty.reward.redemption_cancelled"
|
||||
CAMPAIGN_CREATED = "loyalty.campaign.created"
|
||||
CAMPAIGN_UPDATED = "loyalty.campaign.updated"
|
||||
CAMPAIGN_DELETED = "loyalty.campaign.deleted"
|
||||
CAMPAIGN_SCHEDULED = "loyalty.campaign.scheduled"
|
||||
CAMPAIGN_ACTIVATED = "loyalty.campaign.activated"
|
||||
CAMPAIGN_PAUSED = "loyalty.campaign.paused"
|
||||
CAMPAIGN_RESUMED = "loyalty.campaign.resumed"
|
||||
CAMPAIGN_EXPIRED = "loyalty.campaign.expired"
|
||||
CAMPAIGN_RULES_UPDATED = "loyalty.campaign.rules_updated"
|
||||
CAMPAIGN_APPLIED = "loyalty.campaign.applied"
|
||||
REFERRAL_PROGRAM_CREATED = "loyalty.referral.program_created"
|
||||
REFERRAL_PROGRAM_UPDATED = "loyalty.referral.program_updated"
|
||||
REFERRAL_PROGRAM_DELETED = "loyalty.referral.program_deleted"
|
||||
REFERRAL_CODE_ISSUED = "loyalty.referral.code_issued"
|
||||
REFERRAL_CODE_REVOKED = "loyalty.referral.code_revoked"
|
||||
REFERRAL_ATTRIBUTED = "loyalty.referral.attributed"
|
||||
REFERRAL_CONVERTED = "loyalty.referral.converted"
|
||||
REFERRAL_REWARDED = "loyalty.referral.rewarded"
|
||||
REFERRAL_CANCELLED = "loyalty.referral.cancelled"
|
||||
WALLET_OPENED = "loyalty.wallet.opened"
|
||||
WALLET_CREDITED = "loyalty.wallet.credited"
|
||||
WALLET_DEBITED = "loyalty.wallet.debited"
|
||||
WALLET_ADJUSTED = "loyalty.wallet.adjusted"
|
||||
WALLET_TRANSFERRED = "loyalty.wallet.transferred"
|
||||
WALLET_CLOSED = "loyalty.wallet.closed"
|
||||
|
||||
@ -9,4 +9,13 @@ from app.models.foundation import ( # noqa: F401
|
||||
PointAccount,
|
||||
Reward,
|
||||
)
|
||||
from app.models.ledger import PointLedgerEntry # noqa: F401
|
||||
from app.models.outbox import OutboxEvent # noqa: F401
|
||||
from app.models.rewards import RewardRedemption # noqa: F401
|
||||
from app.models.campaigns import CampaignApplication # noqa: F401
|
||||
from app.models.referral import ( # noqa: F401
|
||||
ReferralAttribution,
|
||||
ReferralCode,
|
||||
ReferralProgram,
|
||||
)
|
||||
from app.models.wallet import WalletAccount, WalletLedgerEntry # noqa: F401
|
||||
|
||||
57
backend/services/loyalty/app/models/campaigns.py
Normal file
57
backend/services/loyalty/app/models/campaigns.py
Normal 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",
|
||||
),
|
||||
)
|
||||
@ -282,6 +282,9 @@ class Reward(
|
||||
points_cost: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
eligibility_rules: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
stock_limit: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
redeemed_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
max_per_member: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "program_id", "code", name="uq_rewards_tenant_code"),
|
||||
|
||||
52
backend/services/loyalty/app/models/ledger.py
Normal file
52
backend/services/loyalty/app/models/ledger.py
Normal 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"),
|
||||
)
|
||||
158
backend/services/loyalty/app/models/referral.py
Normal file
158
backend/services/loyalty/app/models/referral.py
Normal 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 referrer→referee tracking.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import (
|
||||
ActorAuditMixin,
|
||||
SoftDeleteMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
UUIDPrimaryKeyMixin,
|
||||
)
|
||||
from app.models.types import (
|
||||
GUID,
|
||||
ReferralAttributionStatus,
|
||||
ReferralCodeStatus,
|
||||
ReferralProgramStatus,
|
||||
)
|
||||
|
||||
|
||||
class ReferralProgram(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
"""Referral program configuration — bonus rules for referrer/referee."""
|
||||
|
||||
__tablename__ = "referral_programs"
|
||||
|
||||
program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[ReferralProgramStatus] = mapped_column(
|
||||
default=ReferralProgramStatus.DRAFT, nullable=False
|
||||
)
|
||||
referrer_bonus_points: Mapped[int] = mapped_column(
|
||||
Integer, default=0, nullable=False
|
||||
)
|
||||
referee_bonus_points: Mapped[int] = mapped_column(
|
||||
Integer, default=0, nullable=False
|
||||
)
|
||||
max_referrals_per_member: Mapped[int | None] = mapped_column(
|
||||
Integer, nullable=True
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"program_id",
|
||||
"code",
|
||||
name="uq_referral_programs_tenant_program_code",
|
||||
),
|
||||
Index("ix_referral_programs_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_referral_programs_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class ReferralCode(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
):
|
||||
"""Referral code owned by a member (the referrer). Status-only lifecycle."""
|
||||
|
||||
__tablename__ = "referral_codes"
|
||||
|
||||
referral_program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
status: Mapped[ReferralCodeStatus] = mapped_column(
|
||||
default=ReferralCodeStatus.ACTIVE, nullable=False
|
||||
)
|
||||
uses_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
created_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_referral_codes_tenant_code"),
|
||||
Index(
|
||||
"ix_referral_codes_program_member",
|
||||
"tenant_id",
|
||||
"referral_program_id",
|
||||
"member_id",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ReferralAttribution(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
):
|
||||
"""Referrer -> referee link and its lifecycle through reward."""
|
||||
|
||||
__tablename__ = "referral_attributions"
|
||||
|
||||
referral_program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
referral_code_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
referrer_member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
referee_member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
status: Mapped[ReferralAttributionStatus] = mapped_column(
|
||||
default=ReferralAttributionStatus.PENDING, nullable=False
|
||||
)
|
||||
referrer_ledger_entry_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
GUID(), nullable=True
|
||||
)
|
||||
referee_ledger_entry_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
GUID(), nullable=True
|
||||
)
|
||||
idempotency_key: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
converted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
rewarded_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
cancelled_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
cancel_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"referral_program_id",
|
||||
"referee_member_id",
|
||||
name="uq_referral_attributions_tenant_program_referee",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_referral_attributions_tenant_idempotency",
|
||||
),
|
||||
Index(
|
||||
"ix_referral_attributions_referrer",
|
||||
"tenant_id",
|
||||
"referral_program_id",
|
||||
"referrer_member_id",
|
||||
),
|
||||
Index("ix_referral_attributions_status", "tenant_id", "status"),
|
||||
)
|
||||
57
backend/services/loyalty/app/models/rewards.py
Normal file
57
backend/services/loyalty/app/models/rewards.py
Normal 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"),
|
||||
)
|
||||
@ -73,6 +73,13 @@ class PointAccountStatus(str, enum.Enum):
|
||||
CLOSED = "closed"
|
||||
|
||||
|
||||
class LedgerEntryType(str, enum.Enum):
|
||||
EARN = "earn"
|
||||
REDEEM = "redeem"
|
||||
ADJUST = "adjust"
|
||||
EXPIRE = "expire"
|
||||
|
||||
|
||||
class RewardType(str, enum.Enum):
|
||||
COUPON = "coupon"
|
||||
DISCOUNT = "discount"
|
||||
@ -98,6 +105,50 @@ class CampaignStatus(str, enum.Enum):
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class WalletAccountStatus(str, enum.Enum):
|
||||
OPEN = "open"
|
||||
FROZEN = "frozen"
|
||||
CLOSED = "closed"
|
||||
|
||||
|
||||
class WalletLedgerEntryType(str, enum.Enum):
|
||||
CREDIT = "credit"
|
||||
DEBIT = "debit"
|
||||
ADJUST = "adjust"
|
||||
TRANSFER_IN = "transfer_in"
|
||||
TRANSFER_OUT = "transfer_out"
|
||||
|
||||
|
||||
class RedemptionStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
FULFILLED = "fulfilled"
|
||||
CANCELLED = "cancelled"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
class CampaignApplicationStatus(str, enum.Enum):
|
||||
APPLIED = "applied"
|
||||
REVERSED = "reversed"
|
||||
|
||||
|
||||
class ReferralProgramStatus(str, enum.Enum):
|
||||
DRAFT = "draft"
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
|
||||
|
||||
class ReferralCodeStatus(str, enum.Enum):
|
||||
ACTIVE = "active"
|
||||
REVOKED = "revoked"
|
||||
|
||||
|
||||
class ReferralAttributionStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
CONVERTED = "converted"
|
||||
REWARDED = "rewarded"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class AuditAction(str, enum.Enum):
|
||||
CREATE = "create"
|
||||
UPDATE = "update"
|
||||
@ -113,3 +164,25 @@ class AuditAction(str, enum.Enum):
|
||||
TRANSFER = "transfer"
|
||||
CANCEL = "cancel"
|
||||
EXPIRE = "expire"
|
||||
EARN = "earn"
|
||||
REDEEM = "redeem"
|
||||
ADJUST = "adjust"
|
||||
POINTS_EXPIRE = "points_expire"
|
||||
REDEEM_REWARD = "redeem_reward"
|
||||
FULFILL_REWARD = "fulfill_reward"
|
||||
CANCEL_REDEMPTION = "cancel_redemption"
|
||||
SCHEDULE = "schedule"
|
||||
PAUSE = "pause"
|
||||
APPLY = "apply"
|
||||
RULES_UPDATE = "rules_update"
|
||||
ISSUE_CODE = "issue_code"
|
||||
REVOKE_CODE = "revoke_code"
|
||||
ATTRIBUTE = "attribute"
|
||||
CONVERT = "convert"
|
||||
REWARD = "reward"
|
||||
WALLET_OPEN = "wallet_open"
|
||||
WALLET_CREDIT = "wallet_credit"
|
||||
WALLET_DEBIT = "wallet_debit"
|
||||
WALLET_ADJUST = "wallet_adjust"
|
||||
WALLET_TRANSFER = "wallet_transfer"
|
||||
WALLET_CLOSE = "wallet_close"
|
||||
|
||||
98
backend/services/loyalty/app/models/wallet.py
Normal file
98
backend/services/loyalty/app/models/wallet.py
Normal 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"),
|
||||
)
|
||||
@ -36,17 +36,64 @@ LOYALTY_POINT_ACCOUNTS_CREATE = "loyalty.point_accounts.create"
|
||||
LOYALTY_POINT_ACCOUNTS_UPDATE = "loyalty.point_accounts.update"
|
||||
LOYALTY_POINT_ACCOUNTS_MANAGE = "loyalty.point_accounts.manage"
|
||||
|
||||
LOYALTY_POINTS_VIEW = "loyalty.points.view"
|
||||
LOYALTY_POINTS_EARN = "loyalty.points.earn"
|
||||
LOYALTY_POINTS_REDEEM = "loyalty.points.redeem"
|
||||
LOYALTY_POINTS_ADJUST = "loyalty.points.adjust"
|
||||
LOYALTY_POINTS_EXPIRE = "loyalty.points.expire"
|
||||
LOYALTY_POINTS_MANAGE = "loyalty.points.manage"
|
||||
|
||||
LOYALTY_REWARDS_VIEW = "loyalty.rewards.view"
|
||||
LOYALTY_REWARDS_CREATE = "loyalty.rewards.create"
|
||||
LOYALTY_REWARDS_UPDATE = "loyalty.rewards.update"
|
||||
LOYALTY_REWARDS_DELETE = "loyalty.rewards.delete"
|
||||
LOYALTY_REWARDS_MANAGE = "loyalty.rewards.manage"
|
||||
LOYALTY_REWARDS_REDEEM = "loyalty.rewards.redeem"
|
||||
LOYALTY_REWARDS_FULFILL = "loyalty.rewards.fulfill"
|
||||
LOYALTY_REWARDS_CANCEL = "loyalty.rewards.cancel"
|
||||
|
||||
LOYALTY_REDEMPTIONS_VIEW = "loyalty.redemptions.view"
|
||||
|
||||
LOYALTY_CAMPAIGNS_VIEW = "loyalty.campaigns.view"
|
||||
LOYALTY_CAMPAIGNS_CREATE = "loyalty.campaigns.create"
|
||||
LOYALTY_CAMPAIGNS_UPDATE = "loyalty.campaigns.update"
|
||||
LOYALTY_CAMPAIGNS_DELETE = "loyalty.campaigns.delete"
|
||||
LOYALTY_CAMPAIGNS_MANAGE = "loyalty.campaigns.manage"
|
||||
LOYALTY_CAMPAIGNS_SCHEDULE = "loyalty.campaigns.schedule"
|
||||
LOYALTY_CAMPAIGNS_ACTIVATE = "loyalty.campaigns.activate"
|
||||
LOYALTY_CAMPAIGNS_PAUSE = "loyalty.campaigns.pause"
|
||||
LOYALTY_CAMPAIGNS_RESUME = "loyalty.campaigns.resume"
|
||||
LOYALTY_CAMPAIGNS_EXPIRE = "loyalty.campaigns.expire"
|
||||
LOYALTY_CAMPAIGNS_APPLY = "loyalty.campaigns.apply"
|
||||
LOYALTY_CAMPAIGNS_RULES_UPDATE = "loyalty.campaigns.rules.update"
|
||||
LOYALTY_CAMPAIGNS_APPLICATIONS_VIEW = "loyalty.campaigns.applications.view"
|
||||
|
||||
LOYALTY_REFERRAL_PROGRAMS_VIEW = "loyalty.referral_programs.view"
|
||||
LOYALTY_REFERRAL_PROGRAMS_CREATE = "loyalty.referral_programs.create"
|
||||
LOYALTY_REFERRAL_PROGRAMS_UPDATE = "loyalty.referral_programs.update"
|
||||
LOYALTY_REFERRAL_PROGRAMS_DELETE = "loyalty.referral_programs.delete"
|
||||
LOYALTY_REFERRAL_PROGRAMS_MANAGE = "loyalty.referral_programs.manage"
|
||||
|
||||
LOYALTY_REFERRAL_CODES_VIEW = "loyalty.referral_codes.view"
|
||||
LOYALTY_REFERRAL_CODES_CREATE = "loyalty.referral_codes.create"
|
||||
LOYALTY_REFERRAL_CODES_REVOKE = "loyalty.referral_codes.revoke"
|
||||
LOYALTY_REFERRAL_CODES_MANAGE = "loyalty.referral_codes.manage"
|
||||
|
||||
LOYALTY_REFERRALS_VIEW = "loyalty.referrals.view"
|
||||
LOYALTY_REFERRALS_ATTRIBUTE = "loyalty.referrals.attribute"
|
||||
LOYALTY_REFERRALS_CONVERT = "loyalty.referrals.convert"
|
||||
LOYALTY_REFERRALS_REWARD = "loyalty.referrals.reward"
|
||||
LOYALTY_REFERRALS_CANCEL = "loyalty.referrals.cancel"
|
||||
LOYALTY_REFERRALS_MANAGE = "loyalty.referrals.manage"
|
||||
|
||||
LOYALTY_WALLETS_VIEW = "loyalty.wallets.view"
|
||||
LOYALTY_WALLETS_CREATE = "loyalty.wallets.create"
|
||||
LOYALTY_WALLETS_CREDIT = "loyalty.wallets.credit"
|
||||
LOYALTY_WALLETS_DEBIT = "loyalty.wallets.debit"
|
||||
LOYALTY_WALLETS_ADJUST = "loyalty.wallets.adjust"
|
||||
LOYALTY_WALLETS_TRANSFER = "loyalty.wallets.transfer"
|
||||
LOYALTY_WALLETS_DELETE = "loyalty.wallets.delete"
|
||||
LOYALTY_WALLETS_MANAGE = "loyalty.wallets.manage"
|
||||
|
||||
LOYALTY_AUDIT_VIEW = "loyalty.audit.view"
|
||||
|
||||
@ -81,16 +128,57 @@ ALL_PERMISSIONS: list[str] = [
|
||||
LOYALTY_POINT_ACCOUNTS_CREATE,
|
||||
LOYALTY_POINT_ACCOUNTS_UPDATE,
|
||||
LOYALTY_POINT_ACCOUNTS_MANAGE,
|
||||
LOYALTY_POINTS_VIEW,
|
||||
LOYALTY_POINTS_EARN,
|
||||
LOYALTY_POINTS_REDEEM,
|
||||
LOYALTY_POINTS_ADJUST,
|
||||
LOYALTY_POINTS_EXPIRE,
|
||||
LOYALTY_POINTS_MANAGE,
|
||||
LOYALTY_REWARDS_VIEW,
|
||||
LOYALTY_REWARDS_CREATE,
|
||||
LOYALTY_REWARDS_UPDATE,
|
||||
LOYALTY_REWARDS_DELETE,
|
||||
LOYALTY_REWARDS_MANAGE,
|
||||
LOYALTY_REWARDS_REDEEM,
|
||||
LOYALTY_REWARDS_FULFILL,
|
||||
LOYALTY_REWARDS_CANCEL,
|
||||
LOYALTY_REDEMPTIONS_VIEW,
|
||||
LOYALTY_CAMPAIGNS_VIEW,
|
||||
LOYALTY_CAMPAIGNS_CREATE,
|
||||
LOYALTY_CAMPAIGNS_UPDATE,
|
||||
LOYALTY_CAMPAIGNS_DELETE,
|
||||
LOYALTY_CAMPAIGNS_MANAGE,
|
||||
LOYALTY_CAMPAIGNS_SCHEDULE,
|
||||
LOYALTY_CAMPAIGNS_ACTIVATE,
|
||||
LOYALTY_CAMPAIGNS_PAUSE,
|
||||
LOYALTY_CAMPAIGNS_RESUME,
|
||||
LOYALTY_CAMPAIGNS_EXPIRE,
|
||||
LOYALTY_CAMPAIGNS_APPLY,
|
||||
LOYALTY_CAMPAIGNS_RULES_UPDATE,
|
||||
LOYALTY_CAMPAIGNS_APPLICATIONS_VIEW,
|
||||
LOYALTY_REFERRAL_PROGRAMS_VIEW,
|
||||
LOYALTY_REFERRAL_PROGRAMS_CREATE,
|
||||
LOYALTY_REFERRAL_PROGRAMS_UPDATE,
|
||||
LOYALTY_REFERRAL_PROGRAMS_DELETE,
|
||||
LOYALTY_REFERRAL_PROGRAMS_MANAGE,
|
||||
LOYALTY_REFERRAL_CODES_VIEW,
|
||||
LOYALTY_REFERRAL_CODES_CREATE,
|
||||
LOYALTY_REFERRAL_CODES_REVOKE,
|
||||
LOYALTY_REFERRAL_CODES_MANAGE,
|
||||
LOYALTY_REFERRALS_VIEW,
|
||||
LOYALTY_REFERRALS_ATTRIBUTE,
|
||||
LOYALTY_REFERRALS_CONVERT,
|
||||
LOYALTY_REFERRALS_REWARD,
|
||||
LOYALTY_REFERRALS_CANCEL,
|
||||
LOYALTY_REFERRALS_MANAGE,
|
||||
LOYALTY_WALLETS_VIEW,
|
||||
LOYALTY_WALLETS_CREATE,
|
||||
LOYALTY_WALLETS_CREDIT,
|
||||
LOYALTY_WALLETS_DEBIT,
|
||||
LOYALTY_WALLETS_ADJUST,
|
||||
LOYALTY_WALLETS_TRANSFER,
|
||||
LOYALTY_WALLETS_DELETE,
|
||||
LOYALTY_WALLETS_MANAGE,
|
||||
LOYALTY_AUDIT_VIEW,
|
||||
]
|
||||
|
||||
@ -100,7 +188,13 @@ PERMISSION_PREFIXES: tuple[str, ...] = (
|
||||
"loyalty.tiers.",
|
||||
"loyalty.members.",
|
||||
"loyalty.point_accounts.",
|
||||
"loyalty.points.",
|
||||
"loyalty.rewards.",
|
||||
"loyalty.redemptions.",
|
||||
"loyalty.campaigns.",
|
||||
"loyalty.referral_programs.",
|
||||
"loyalty.referral_codes.",
|
||||
"loyalty.referrals.",
|
||||
"loyalty.wallets.",
|
||||
"loyalty.audit.",
|
||||
)
|
||||
|
||||
77
backend/services/loyalty/app/repositories/campaigns.py
Normal file
77
backend/services/loyalty/app/repositories/campaigns.py
Normal 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())
|
||||
92
backend/services/loyalty/app/repositories/ledger.py
Normal file
92
backend/services/loyalty/app/repositories/ledger.py
Normal 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())
|
||||
162
backend/services/loyalty/app/repositories/referral.py
Normal file
162
backend/services/loyalty/app/repositories/referral.py
Normal 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())
|
||||
105
backend/services/loyalty/app/repositories/rewards.py
Normal file
105
backend/services/loyalty/app/repositories/rewards.py
Normal 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)
|
||||
105
backend/services/loyalty/app/repositories/wallet.py
Normal file
105
backend/services/loyalty/app/repositories/wallet.py
Normal 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())
|
||||
52
backend/services/loyalty/app/schemas/campaigns.py
Normal file
52
backend/services/loyalty/app/schemas/campaigns.py
Normal 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
|
||||
@ -285,6 +285,8 @@ class RewardCreate(BaseModel):
|
||||
points_cost: int = Field(default=0, ge=0)
|
||||
eligibility_rules: dict | None = None
|
||||
metadata_json: dict | None = None
|
||||
stock_limit: int | None = Field(default=None, ge=0)
|
||||
max_per_member: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class RewardUpdate(BaseModel):
|
||||
@ -297,6 +299,8 @@ class RewardUpdate(BaseModel):
|
||||
points_cost: int | None = Field(default=None, ge=0)
|
||||
eligibility_rules: dict | None = None
|
||||
metadata_json: dict | None = None
|
||||
stock_limit: int | None = Field(default=None, ge=0)
|
||||
max_per_member: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class RewardRead(ORMBase):
|
||||
@ -311,6 +315,9 @@ class RewardRead(ORMBase):
|
||||
points_cost: int
|
||||
eligibility_rules: dict | None
|
||||
metadata_json: dict | None
|
||||
stock_limit: int | None
|
||||
redeemed_count: int
|
||||
max_per_member: int | None
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
|
||||
92
backend/services/loyalty/app/schemas/ledger.py
Normal file
92
backend/services/loyalty/app/schemas/ledger.py
Normal 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
|
||||
133
backend/services/loyalty/app/schemas/referral.py
Normal file
133
backend/services/loyalty/app/schemas/referral.py
Normal 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
|
||||
56
backend/services/loyalty/app/schemas/rewards.py
Normal file
56
backend/services/loyalty/app/schemas/rewards.py
Normal 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
|
||||
125
backend/services/loyalty/app/schemas/wallet.py
Normal file
125
backend/services/loyalty/app/schemas/wallet.py
Normal 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
|
||||
311
backend/services/loyalty/app/services/campaign_engine.py
Normal file
311
backend/services/loyalty/app/services/campaign_engine.py
Normal 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
|
||||
@ -20,7 +20,13 @@ from app.models.foundation import (
|
||||
PointAccount,
|
||||
Reward,
|
||||
)
|
||||
from app.models.types import AuditAction, MemberStatus, MembershipLifecycleAction, PointAccountStatus
|
||||
from app.models.types import (
|
||||
AuditAction,
|
||||
CampaignStatus,
|
||||
MemberStatus,
|
||||
MembershipLifecycleAction,
|
||||
PointAccountStatus,
|
||||
)
|
||||
from app.repositories.foundation import (
|
||||
CampaignRepository,
|
||||
LoyaltyProgramRepository,
|
||||
@ -992,6 +998,8 @@ class RewardService:
|
||||
points_cost=validate_non_negative_int(body.points_cost, field="points_cost"),
|
||||
eligibility_rules=body.eligibility_rules,
|
||||
metadata_json=body.metadata_json,
|
||||
stock_limit=body.stock_limit,
|
||||
max_per_member=body.max_per_member,
|
||||
created_by=actor_id,
|
||||
updated_by=actor_id,
|
||||
)
|
||||
@ -1237,6 +1245,12 @@ class CampaignService:
|
||||
actor: CurrentUser | None = None,
|
||||
) -> Campaign:
|
||||
entity = await self.get(tenant_id, campaign_id)
|
||||
if entity.status == CampaignStatus.ACTIVE:
|
||||
raise AppError(
|
||||
"کمپین فعال قابل حذف نیست",
|
||||
status_code=409,
|
||||
error_code="campaign_active_not_deletable",
|
||||
)
|
||||
entity.code = release_unique_token(entity.code, entity.id)
|
||||
await self.repo.soft_delete(
|
||||
entity, deleted_by=actor.user_id if actor else None
|
||||
|
||||
291
backend/services/loyalty/app/services/point_engine.py
Normal file
291
backend/services/loyalty/app/services/point_engine.py
Normal 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,
|
||||
)
|
||||
672
backend/services/loyalty/app/services/referral_engine.py
Normal file
672
backend/services/loyalty/app/services/referral_engine.py
Normal 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
|
||||
293
backend/services/loyalty/app/services/rewards_engine.py
Normal file
293
backend/services/loyalty/app/services/rewards_engine.py
Normal 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
|
||||
487
backend/services/loyalty/app/services/wallet_engine.py
Normal file
487
backend/services/loyalty/app/services/wallet_engine.py
Normal 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
|
||||
@ -15,7 +15,7 @@ async def test_health(client):
|
||||
body = resp.json()
|
||||
assert body["service"] == "loyalty-service"
|
||||
assert body["status"] == "ok"
|
||||
assert body["version"] == "0.7.1.0"
|
||||
assert body["version"] == "0.7.6.0"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@ -12,9 +12,9 @@ def test_alembic_revisions_exist():
|
||||
scripts = ScriptDirectory.from_config(cfg)
|
||||
revisions = list(scripts.walk_revisions())
|
||||
assert revisions
|
||||
assert revisions[0].revision == "0002_phase_71_membership"
|
||||
assert revisions[0].revision == "0007_phase_76_wallet"
|
||||
assert revisions[-1].revision == "0001_initial"
|
||||
assert revisions[0].down_revision == "0001_initial"
|
||||
assert revisions[0].down_revision == "0006_phase_75_referral"
|
||||
assert revisions[-1].down_revision is None
|
||||
|
||||
|
||||
@ -32,6 +32,14 @@ def test_migration_upgrade_creates_metadata_tables(db_setup):
|
||||
"loyalty_audit_logs",
|
||||
"outbox_events",
|
||||
"membership_lifecycle_events",
|
||||
"point_ledger_entries",
|
||||
"reward_redemptions",
|
||||
"campaign_applications",
|
||||
"referral_programs",
|
||||
"referral_codes",
|
||||
"referral_attributions",
|
||||
"wallet_accounts",
|
||||
"wallet_ledger_entries",
|
||||
}
|
||||
assert expected.issubset(set(Base.metadata.tables.keys()))
|
||||
assert "ix_members_tenant_tier" in {
|
||||
|
||||
@ -16,6 +16,7 @@ def test_required_permission_trees_present():
|
||||
"loyalty.members.",
|
||||
"loyalty.point_accounts.",
|
||||
"loyalty.rewards.",
|
||||
"loyalty.redemptions.",
|
||||
"loyalty.campaigns.",
|
||||
"loyalty.audit.",
|
||||
]
|
||||
@ -29,6 +30,7 @@ def test_permission_prefixes_documented():
|
||||
assert "loyalty.members." in PERMISSION_PREFIXES
|
||||
assert "loyalty.point_accounts." in PERMISSION_PREFIXES
|
||||
assert "loyalty.rewards." in PERMISSION_PREFIXES
|
||||
assert "loyalty.redemptions." in PERMISSION_PREFIXES
|
||||
assert "loyalty.campaigns." in PERMISSION_PREFIXES
|
||||
|
||||
|
||||
|
||||
114
backend/services/loyalty/app/tests/test_phase72.py
Normal file
114
backend/services/loyalty/app/tests/test_phase72.py
Normal 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
|
||||
359
backend/services/loyalty/app/tests/test_phase73.py
Normal file
359
backend/services/loyalty/app/tests/test_phase73.py
Normal 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"]
|
||||
402
backend/services/loyalty/app/tests/test_phase74.py
Normal file
402
backend/services/loyalty/app/tests/test_phase74.py
Normal 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"]
|
||||
468
backend/services/loyalty/app/tests/test_phase75.py
Normal file
468
backend/services/loyalty/app/tests/test_phase75.py
Normal 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"
|
||||
339
backend/services/loyalty/app/tests/test_phase76.py
Normal file
339
backend/services/loyalty/app/tests/test_phase76.py
Normal 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())
|
||||
@ -26,6 +26,34 @@ from app.validators.membership import (
|
||||
target_status_for,
|
||||
validate_reason,
|
||||
)
|
||||
from app.validators.rewards import (
|
||||
ensure_cancellable,
|
||||
ensure_fulfillable,
|
||||
ensure_max_per_member,
|
||||
ensure_member_active,
|
||||
ensure_reward_active,
|
||||
ensure_stock_available,
|
||||
)
|
||||
from app.validators.campaigns import (
|
||||
compute_grant_amount,
|
||||
ensure_campaign_active,
|
||||
ensure_campaign_transition,
|
||||
ensure_campaign_window,
|
||||
ensure_max_applications_per_member,
|
||||
ensure_positive_grant,
|
||||
ensure_schedule_requires_start_date,
|
||||
target_campaign_status,
|
||||
)
|
||||
from app.validators.referral import (
|
||||
ensure_attribution_cancellable,
|
||||
ensure_attribution_converted,
|
||||
ensure_attribution_pending,
|
||||
ensure_code_active,
|
||||
ensure_max_referrals_not_exceeded,
|
||||
ensure_not_self_referral,
|
||||
ensure_referral_program_active,
|
||||
validate_referral_program_status,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"validate_non_empty",
|
||||
@ -51,4 +79,26 @@ __all__ = [
|
||||
"validate_reason",
|
||||
"DEFAULT_TERM_DAYS",
|
||||
"ACTIVE_MEMBER_STATUSES",
|
||||
"ensure_reward_active",
|
||||
"ensure_stock_available",
|
||||
"ensure_max_per_member",
|
||||
"ensure_member_active",
|
||||
"ensure_fulfillable",
|
||||
"ensure_cancellable",
|
||||
"ensure_campaign_transition",
|
||||
"target_campaign_status",
|
||||
"ensure_schedule_requires_start_date",
|
||||
"ensure_campaign_active",
|
||||
"ensure_campaign_window",
|
||||
"ensure_max_applications_per_member",
|
||||
"compute_grant_amount",
|
||||
"ensure_positive_grant",
|
||||
"validate_referral_program_status",
|
||||
"ensure_referral_program_active",
|
||||
"ensure_code_active",
|
||||
"ensure_not_self_referral",
|
||||
"ensure_max_referrals_not_exceeded",
|
||||
"ensure_attribution_pending",
|
||||
"ensure_attribution_converted",
|
||||
"ensure_attribution_cancellable",
|
||||
]
|
||||
|
||||
122
backend/services/loyalty/app/validators/campaigns.py
Normal file
122
backend/services/loyalty/app/validators/campaigns.py
Normal 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
|
||||
51
backend/services/loyalty/app/validators/ledger.py
Normal file
51
backend/services/loyalty/app/validators/ledger.py
Normal 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
|
||||
95
backend/services/loyalty/app/validators/referral.py
Normal file
95
backend/services/loyalty/app/validators/referral.py
Normal 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",
|
||||
)
|
||||
61
backend/services/loyalty/app/validators/rewards.py
Normal file
61
backend/services/loyalty/app/validators/rewards.py
Normal 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",
|
||||
)
|
||||
95
backend/services/loyalty/app/validators/wallet.py
Normal file
95
backend/services/loyalty/app/validators/wallet.py
Normal 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",
|
||||
)
|
||||
@ -312,6 +312,181 @@ services:
|
||||
networks:
|
||||
- superapp_net
|
||||
|
||||
delivery-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/services/delivery/Dockerfile.dev
|
||||
container_name: superapp_delivery_service
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DELIVERY_DATABASE_URL: ${DELIVERY_DATABASE_URL}
|
||||
DELIVERY_DATABASE_URL_SYNC: ${DELIVERY_DATABASE_URL_SYNC}
|
||||
CORE_SERVICE_URL: ${CORE_SERVICE_URL:-http://core-service:8000}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000}
|
||||
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||
KEYCLOAK_SERVER_URL: ${KEYCLOAK_SERVER_URL:-http://keycloak:8080}
|
||||
KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL:-http://localhost:8080}
|
||||
KEYCLOAK_REALM: ${KEYCLOAK_REALM:-superapp}
|
||||
JWT_VERIFY_SIGNATURE: ${JWT_VERIFY_SIGNATURE:-true}
|
||||
ports:
|
||||
- "8007:8007"
|
||||
volumes:
|
||||
- ./backend/services/delivery:/app
|
||||
- ./backend/shared-lib:/shared-lib
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
core-service:
|
||||
condition: service_started
|
||||
command: >
|
||||
sh -c "python scripts/ensure_db.py &&
|
||||
alembic upgrade head &&
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8007 --reload"
|
||||
networks:
|
||||
- superapp_net
|
||||
|
||||
healthcare-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/services/healthcare/Dockerfile.dev
|
||||
container_name: superapp_healthcare_service
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
HEALTHCARE_DATABASE_URL: ${HEALTHCARE_DATABASE_URL}
|
||||
HEALTHCARE_DATABASE_URL_SYNC: ${HEALTHCARE_DATABASE_URL_SYNC}
|
||||
CORE_SERVICE_URL: ${CORE_SERVICE_URL:-http://core-service:8000}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000}
|
||||
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||
KEYCLOAK_SERVER_URL: ${KEYCLOAK_SERVER_URL:-http://keycloak:8080}
|
||||
KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL:-http://localhost:8080}
|
||||
KEYCLOAK_REALM: ${KEYCLOAK_REALM:-superapp}
|
||||
JWT_VERIFY_SIGNATURE: ${JWT_VERIFY_SIGNATURE:-true}
|
||||
ports:
|
||||
- "8010:8010"
|
||||
volumes:
|
||||
- ./backend/services/healthcare:/app
|
||||
- ./backend/shared-lib:/shared-lib
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
core-service:
|
||||
condition: service_started
|
||||
command: >
|
||||
sh -c "python scripts/ensure_db.py &&
|
||||
alembic upgrade head &&
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8010 --reload"
|
||||
networks:
|
||||
- superapp_net
|
||||
|
||||
beauty-business-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/services/beauty_business/Dockerfile.dev
|
||||
container_name: superapp_beauty_business_service
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
BEAUTY_BUSINESS_DATABASE_URL: ${BEAUTY_BUSINESS_DATABASE_URL}
|
||||
BEAUTY_BUSINESS_DATABASE_URL_SYNC: ${BEAUTY_BUSINESS_DATABASE_URL_SYNC}
|
||||
CORE_SERVICE_URL: ${CORE_SERVICE_URL:-http://core-service:8000}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000}
|
||||
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||
KEYCLOAK_SERVER_URL: ${KEYCLOAK_SERVER_URL:-http://keycloak:8080}
|
||||
KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL:-http://localhost:8080}
|
||||
KEYCLOAK_REALM: ${KEYCLOAK_REALM:-superapp}
|
||||
JWT_VERIFY_SIGNATURE: ${JWT_VERIFY_SIGNATURE:-true}
|
||||
ports:
|
||||
- "8011:8011"
|
||||
volumes:
|
||||
- ./backend/services/beauty_business:/app
|
||||
- ./backend/shared-lib:/shared-lib
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
core-service:
|
||||
condition: service_started
|
||||
command: >
|
||||
sh -c "python scripts/ensure_db.py &&
|
||||
alembic upgrade head &&
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8011 --reload"
|
||||
networks:
|
||||
- superapp_net
|
||||
|
||||
hospitality-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/services/hospitality/Dockerfile.dev
|
||||
container_name: superapp_hospitality_service
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
HOSPITALITY_DATABASE_URL: ${HOSPITALITY_DATABASE_URL}
|
||||
HOSPITALITY_DATABASE_URL_SYNC: ${HOSPITALITY_DATABASE_URL_SYNC}
|
||||
CORE_SERVICE_URL: ${CORE_SERVICE_URL:-http://core-service:8000}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000}
|
||||
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||
KEYCLOAK_SERVER_URL: ${KEYCLOAK_SERVER_URL:-http://keycloak:8080}
|
||||
KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL:-http://localhost:8080}
|
||||
KEYCLOAK_REALM: ${KEYCLOAK_REALM:-superapp}
|
||||
JWT_VERIFY_SIGNATURE: ${JWT_VERIFY_SIGNATURE:-true}
|
||||
ports:
|
||||
- "8009:8009"
|
||||
volumes:
|
||||
- ./backend/services/hospitality:/app
|
||||
- ./backend/shared-lib:/shared-lib
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
core-service:
|
||||
condition: service_started
|
||||
command: >
|
||||
sh -c "python scripts/ensure_db.py &&
|
||||
alembic upgrade head &&
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8009 --reload"
|
||||
networks:
|
||||
- superapp_net
|
||||
|
||||
experience-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/services/experience/Dockerfile.dev
|
||||
container_name: superapp_experience_service
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
EXPERIENCE_DATABASE_URL: ${EXPERIENCE_DATABASE_URL}
|
||||
EXPERIENCE_DATABASE_URL_SYNC: ${EXPERIENCE_DATABASE_URL_SYNC}
|
||||
CORE_SERVICE_URL: ${CORE_SERVICE_URL:-http://core-service:8000}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000}
|
||||
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||
KEYCLOAK_SERVER_URL: ${KEYCLOAK_SERVER_URL:-http://keycloak:8080}
|
||||
KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL:-http://localhost:8080}
|
||||
KEYCLOAK_REALM: ${KEYCLOAK_REALM:-superapp}
|
||||
JWT_VERIFY_SIGNATURE: ${JWT_VERIFY_SIGNATURE:-true}
|
||||
ports:
|
||||
- "8008:8008"
|
||||
volumes:
|
||||
- ./backend/services/experience:/app
|
||||
- ./backend/shared-lib:/shared-lib
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
core-service:
|
||||
condition: service_started
|
||||
command: >
|
||||
sh -c "python scripts/ensure_db.py &&
|
||||
alembic upgrade head &&
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8008 --reload"
|
||||
networks:
|
||||
- superapp_net
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
@ -330,6 +505,12 @@ services:
|
||||
NEXT_PUBLIC_PLATFORM_BASE_DOMAIN: ${NEXT_PUBLIC_PLATFORM_BASE_DOMAIN:-torbatyar.ir}
|
||||
NEXT_PUBLIC_ACCOUNTING_API_URL: ${NEXT_PUBLIC_ACCOUNTING_API_URL:-http://localhost:8002}
|
||||
ACCOUNTING_SERVICE_URL: ${ACCOUNTING_SERVICE_URL:-http://accounting-service:8002}
|
||||
NEXT_PUBLIC_HEALTHCARE_API_URL: ${NEXT_PUBLIC_HEALTHCARE_API_URL:-http://localhost:8010}
|
||||
HEALTHCARE_SERVICE_URL: ${HEALTHCARE_SERVICE_URL:-http://healthcare-service:8010}
|
||||
NEXT_PUBLIC_BEAUTY_BUSINESS_API_URL: ${NEXT_PUBLIC_BEAUTY_BUSINESS_API_URL:-http://localhost:8011}
|
||||
BEAUTY_BUSINESS_SERVICE_URL: ${BEAUTY_BUSINESS_SERVICE_URL:-http://beauty-business-service:8011}
|
||||
NEXT_PUBLIC_HOSPITALITY_API_URL: ${NEXT_PUBLIC_HOSPITALITY_API_URL:-http://localhost:8009}
|
||||
HOSPITALITY_SERVICE_URL: ${HOSPITALITY_SERVICE_URL:-http://hospitality-service:8009}
|
||||
# polling برای hot-reload پایدار روی volume mount ویندوز
|
||||
WATCHPACK_POLLING: "true"
|
||||
CHOKIDAR_USEPOLLING: "true"
|
||||
|
||||
@ -5,9 +5,14 @@
|
||||
# Agents must keep this file aligned with docs/progress.md and docs/roadmap.md.
|
||||
|
||||
schema_version: 1
|
||||
updated: "2026-07-25"
|
||||
# Delivery Platform registration complete; next delivery-10.0 Foundation.
|
||||
# Loyalty Phase 7.1 complete; 7.2 Point Engine next for Loyalty track.
|
||||
updated: "2026-07-26"
|
||||
# AF-Discovery complete: Enterprise Phase Discovery mandate (ADR-019).
|
||||
# AF-Enterprise complete: Enterprise Development Framework upgrade (ADR-018).
|
||||
# Hospitality Phase 12.3 complete; 12.4 POS Lite next for Hospitality track.
|
||||
# Experience 11.2 Component Library complete; next experience-11.3 was completed — next experience-11.4 Template System.
|
||||
# Experience 11.3 Theme & Layout complete; next experience-11.4 Template System.
|
||||
# Delivery Platform registration complete; Phase 10.1 Driver Management complete; next delivery-10.2.
|
||||
# Loyalty Phase 7.6 complete; 7.7 Gift Card Platform next for Loyalty track.
|
||||
|
||||
phases:
|
||||
- id: core-1
|
||||
@ -85,6 +90,64 @@ phases:
|
||||
- docs/architecture/adr/ADR-013.md
|
||||
required_services: []
|
||||
|
||||
- id: ai-framework-enterprise
|
||||
name: Enterprise Development Framework Upgrade
|
||||
area: Platform
|
||||
description: Docs-only upgrade of the AI Development Framework into an Enterprise Development Framework — Definition of Done, mandatory phase artifacts, enterprise completeness, boundary rules, expanded gates/loop/templates. No business feature code. Extends ADR-013 via ADR-018. Does not modify completed business phases or implementations.
|
||||
status: complete
|
||||
dependencies:
|
||||
- ai-framework
|
||||
required_previous_phase: ai-framework
|
||||
required_documents:
|
||||
- docs/ai-framework/README.md
|
||||
- docs/ai-framework/definition-of-done.md
|
||||
- docs/ai-framework/mandatory-phase-artifacts.md
|
||||
- docs/ai-framework/enterprise-completeness.md
|
||||
- docs/ai-framework/boundary-rules.md
|
||||
- docs/ai-framework/master-prompt.md
|
||||
- docs/ai-framework/development-loop.md
|
||||
- docs/ai-framework/quality-gates.md
|
||||
- docs/ai-framework/phase-template.md
|
||||
- docs/ai-framework/phase-handover.md
|
||||
- docs/architecture/adr/ADR-018.md
|
||||
- docs/phase-handover/phase-af-enterprise.md
|
||||
required_services: []
|
||||
completion_criteria:
|
||||
- DoD, mandatory artifacts, enterprise completeness, and boundary rules documents published
|
||||
- Master prompt, development loop, quality gates, templates, prompt/cursor rules updated
|
||||
- ADR-018 accepted; ADR index and glossary updated
|
||||
- Framework handover complete; no business code changes
|
||||
- Backward-compatible path docs/ai-framework/ retained
|
||||
- Quality gates for documentation/architecture/manifest validation passed
|
||||
|
||||
- id: ai-framework-discovery
|
||||
name: Enterprise Phase Discovery Mandate
|
||||
area: Platform
|
||||
description: Docs-only framework enhancement — mandatory Enterprise Phase Discovery before every phase implementation. Agents must inventory roadmap, architecture, boundaries, prior handover, codebase, APIs, domain, events, permissions, aggregates, tests, and docs; derive missing current-phase production capabilities; never assume CRUD; never pull future phases or violate boundaries. ADR-019 extends ADR-018. No business feature code.
|
||||
status: complete
|
||||
dependencies:
|
||||
- ai-framework-enterprise
|
||||
required_previous_phase: ai-framework-enterprise
|
||||
required_documents:
|
||||
- docs/ai-framework/enterprise-phase-discovery.md
|
||||
- docs/ai-framework/README.md
|
||||
- docs/ai-framework/master-prompt.md
|
||||
- docs/ai-framework/development-loop.md
|
||||
- docs/ai-framework/quality-gates.md
|
||||
- docs/ai-framework/phase-template.md
|
||||
- docs/ai-framework/phase-handover.md
|
||||
- docs/ai-framework/definition-of-done.md
|
||||
- docs/architecture/adr/ADR-019.md
|
||||
- docs/phase-handover/phase-af-discovery.md
|
||||
required_services: []
|
||||
completion_criteria:
|
||||
- enterprise-phase-discovery.md published with inputs, procedure, checklist, Discovery Record
|
||||
- Loop stage 1 is Discovery; quality gates include Discovery gate
|
||||
- Phase template/handover/prompt/cursor/DoD/completeness updated
|
||||
- ADR-019 accepted; glossary/progress/manifests updated
|
||||
- Framework handover complete; no business code changes
|
||||
- Quality gates for documentation/architecture/manifest validation passed
|
||||
|
||||
# --- Accounting ---
|
||||
- id: accounting-5.1
|
||||
name: Accounting Foundation
|
||||
@ -300,27 +363,142 @@ phases:
|
||||
- id: loyalty-7.2
|
||||
name: Point Engine
|
||||
area: Loyalty
|
||||
status: planned
|
||||
status: complete
|
||||
dependencies:
|
||||
- loyalty-7.1
|
||||
- ai-framework
|
||||
required_documents:
|
||||
- docs/loyalty-phase-7-2.md
|
||||
- docs/phase-handover/phase-7-2.md
|
||||
- docs/phases/Loyalty/README.md
|
||||
- docs/architecture/adr/ADR-011.md
|
||||
- docs/service-snapshots/loyalty.yaml
|
||||
required_services:
|
||||
- loyalty
|
||||
|
||||
- id: loyalty-7.3
|
||||
name: Rewards Engine
|
||||
area: Loyalty
|
||||
status: complete
|
||||
dependencies:
|
||||
- loyalty-7.2
|
||||
- ai-framework
|
||||
required_documents:
|
||||
- docs/loyalty-phase-7-3.md
|
||||
- docs/phase-handover/phase-7-3.md
|
||||
- docs/phases/Loyalty/README.md
|
||||
- docs/architecture/adr/ADR-011.md
|
||||
- docs/service-snapshots/loyalty.yaml
|
||||
required_services:
|
||||
- loyalty
|
||||
|
||||
- id: loyalty-7.4
|
||||
name: Campaign Engine
|
||||
area: Loyalty
|
||||
status: complete
|
||||
dependencies:
|
||||
- loyalty-7.3
|
||||
- ai-framework
|
||||
required_documents:
|
||||
- docs/loyalty-phase-7-4.md
|
||||
- docs/phase-handover/phase-7-4.md
|
||||
- docs/service-snapshots/loyalty.yaml
|
||||
required_services:
|
||||
- loyalty
|
||||
|
||||
- id: loyalty-7.5
|
||||
name: Referral Engine
|
||||
area: Loyalty
|
||||
status: complete
|
||||
dependencies:
|
||||
- loyalty-7.4
|
||||
- ai-framework
|
||||
required_documents:
|
||||
- docs/loyalty-phase-7-5.md
|
||||
- docs/phase-handover/phase-7-5.md
|
||||
- docs/service-snapshots/loyalty.yaml
|
||||
required_services:
|
||||
- loyalty
|
||||
|
||||
- id: loyalty-7.6
|
||||
name: Wallet
|
||||
area: Loyalty
|
||||
status: complete
|
||||
dependencies:
|
||||
- loyalty-7.5
|
||||
- ai-framework
|
||||
required_documents:
|
||||
- docs/loyalty-phase-7-6.md
|
||||
- docs/phase-handover/phase-7-6.md
|
||||
- docs/phases/Loyalty/README.md
|
||||
- docs/architecture/adr/ADR-011.md
|
||||
- docs/service-snapshots/loyalty.yaml
|
||||
required_services:
|
||||
- loyalty
|
||||
|
||||
- id: loyalty-7.7
|
||||
name: Gift Card Platform
|
||||
area: Loyalty
|
||||
status: planned
|
||||
dependencies:
|
||||
- loyalty-7.6
|
||||
required_documents:
|
||||
- docs/phases/Loyalty/README.md
|
||||
- docs/ai-framework/phase-template.md
|
||||
required_services:
|
||||
- loyalty
|
||||
|
||||
- id: loyalty-7.8
|
||||
name: Analytics
|
||||
area: Loyalty
|
||||
status: planned
|
||||
dependencies:
|
||||
- loyalty-7.7
|
||||
required_documents:
|
||||
- docs/phases/Loyalty/README.md
|
||||
required_services:
|
||||
- loyalty
|
||||
|
||||
- id: loyalty-7.9
|
||||
name: Public / Partner / Webhook APIs
|
||||
area: Loyalty
|
||||
status: planned
|
||||
dependencies:
|
||||
- loyalty-7.8
|
||||
required_documents:
|
||||
- docs/phases/Loyalty/README.md
|
||||
required_services:
|
||||
- loyalty
|
||||
|
||||
- id: loyalty-7.10
|
||||
name: Final Review
|
||||
area: Loyalty
|
||||
status: planned
|
||||
dependencies:
|
||||
- loyalty-7.9
|
||||
required_documents:
|
||||
- docs/phases/Loyalty/README.md
|
||||
required_services:
|
||||
- loyalty
|
||||
|
||||
# --- SMS / Communication ---
|
||||
- id: communication-8
|
||||
name: Enterprise Communication Platform (SMS-first)
|
||||
area: SMS
|
||||
description: Independent communication-service (SMS MVP). Production Ready — feature-complete for current MVP scope; future channels in communication-roadmap.md only.
|
||||
status: complete
|
||||
dependencies:
|
||||
- onboarding-4
|
||||
required_documents:
|
||||
- docs/communication-phase-8.md
|
||||
- docs/communication-phase-8-audit.md
|
||||
- docs/communication-roadmap.md
|
||||
- docs/phase-handover/phase-8.md
|
||||
- docs/service-snapshots/communication.yaml
|
||||
- docs/architecture/adr/ADR-012.md
|
||||
required_services:
|
||||
- communication
|
||||
notes: MVP feature-complete (SMS). No registered 9.x phases. Email/Push/Telegram/WhatsApp/Rubika/Voice remain future roadmap items.
|
||||
|
||||
- id: sms-panel-future
|
||||
name: Legacy SMS Panel Scaffold Alignment
|
||||
@ -410,7 +588,7 @@ phases:
|
||||
name: Coach & Staff Management
|
||||
area: Sports Center
|
||||
description: Coaches, trainers, nutritionists, medical staff, reception, managers, working hours, certificates, skills, payroll refs, availability, permissions.
|
||||
status: planned
|
||||
status: complete
|
||||
dependencies:
|
||||
- sports-center-9.2
|
||||
required_previous_phase: sports-center-9.2
|
||||
@ -428,7 +606,7 @@ phases:
|
||||
name: Scheduling & Booking
|
||||
area: Sports Center
|
||||
description: Sports classes, sessions, timetables, reservations, capacity, waiting lists, recurring sessions, resource allocation, facilities, equipment reservation, calendar.
|
||||
status: planned
|
||||
status: complete
|
||||
dependencies:
|
||||
- sports-center-9.3
|
||||
required_previous_phase: sports-center-9.3
|
||||
@ -445,7 +623,7 @@ phases:
|
||||
name: Attendance & Access Control
|
||||
area: Sports Center
|
||||
description: Attendance, QR/RFID/barcode/face-ready check-in, manual attendance, entry/exit gates, late arrival, absence, attendance history.
|
||||
status: planned
|
||||
status: complete
|
||||
dependencies:
|
||||
- sports-center-9.4
|
||||
required_previous_phase: sports-center-9.4
|
||||
@ -462,7 +640,7 @@ phases:
|
||||
name: Training Management
|
||||
area: Sports Center
|
||||
description: Training programs, exercises, workout plans, goals, measurements, progress tracking, nutrition plans, body composition, assessments, performance records.
|
||||
status: planned
|
||||
status: complete
|
||||
dependencies:
|
||||
- sports-center-9.5
|
||||
required_previous_phase: sports-center-9.5
|
||||
@ -479,7 +657,7 @@ phases:
|
||||
name: Competition & Event Management
|
||||
area: Sports Center
|
||||
description: Competitions, tournaments, matches, teams, groups, registrations, rankings, results, medals, certificates, judges, schedules.
|
||||
status: planned
|
||||
status: complete
|
||||
dependencies:
|
||||
- sports-center-9.6
|
||||
required_previous_phase: sports-center-9.6
|
||||
@ -594,7 +772,7 @@ phases:
|
||||
name: Delivery Platform Foundation
|
||||
area: Delivery
|
||||
description: Independent delivery service scaffold, delivery_db, health/capabilities/metrics, permissions delivery.*, publish-only event shells, tenant isolation, audit/config shells, provider/routing-engine contracts only.
|
||||
status: planned
|
||||
status: complete
|
||||
dependencies:
|
||||
- delivery-reg
|
||||
required_previous_phase: delivery-reg
|
||||
@ -603,6 +781,8 @@ phases:
|
||||
- docs/phases/Delivery/README.md
|
||||
- docs/architecture/adr/ADR-015.md
|
||||
- docs/phase-handover/phase-dp-reg.md
|
||||
- docs/delivery-phase-10-0.md
|
||||
- docs/phase-handover/phase-10-0.md
|
||||
- docs/ai-framework/service-template.md
|
||||
- docs/ai-framework/phase-template.md
|
||||
required_services:
|
||||
@ -619,7 +799,7 @@ phases:
|
||||
name: Driver Management
|
||||
area: Delivery
|
||||
description: Drivers, profiles, credential refs, status lifecycle, permissions, events, APIs.
|
||||
status: planned
|
||||
status: complete
|
||||
dependencies:
|
||||
- delivery-10.0
|
||||
required_previous_phase: delivery-10.0
|
||||
@ -628,11 +808,15 @@ phases:
|
||||
- docs/phases/Delivery/README.md
|
||||
- docs/architecture/adr/ADR-015.md
|
||||
- docs/ai-framework/module-template.md
|
||||
- docs/delivery-phase-10-1.md
|
||||
- docs/phase-handover/phase-10-1.md
|
||||
required_services:
|
||||
- delivery
|
||||
completion_criteria:
|
||||
- Driver management APIs + validators + tests green
|
||||
- Lifecycle, credentials/documents, outbox, list filter/sort/search
|
||||
- No fleet/dispatch/routing engines yet
|
||||
- Quality gates passed; handover complete
|
||||
|
||||
- id: delivery-10.2
|
||||
name: Fleet & Vehicle Types
|
||||
@ -800,19 +984,477 @@ phases:
|
||||
- Module registry version/phase updated; progress marks 10.10 complete
|
||||
- No undocumented public API/event/permission
|
||||
|
||||
# --- Restaurant ---
|
||||
- id: restaurant-foundation
|
||||
name: Restaurant / Cafe Foundation
|
||||
area: Restaurant
|
||||
# --- Experience Platform / Torbat Pages (Phase 11.0–11.10) ---
|
||||
- id: experience-reg
|
||||
name: Experience Platform Registration
|
||||
area: Experience
|
||||
description: Documentation-only registration of independent experience service, experience_db, phases 11.0–11.10, ADR-016, module/glossary/boundary updates. No business code. Prefer experience over historical website_builder scaffold.
|
||||
status: complete
|
||||
dependencies:
|
||||
- onboarding-4
|
||||
- ai-framework
|
||||
required_previous_phase: ai-framework
|
||||
required_documents:
|
||||
- docs/experience-roadmap.md
|
||||
- docs/phases/Experience/README.md
|
||||
- docs/architecture/adr/ADR-016.md
|
||||
- docs/experience-phase-xp-reg.md
|
||||
- docs/phase-handover/phase-xp-reg.md
|
||||
- docs/ai-framework/service-template.md
|
||||
- docs/ai-framework/phase-template.md
|
||||
required_services:
|
||||
- experience
|
||||
completion_criteria:
|
||||
- Service registered in service-manifest with experience_db and experience.* permissions
|
||||
- Phases 11.0–11.10 registered in phase-manifest
|
||||
- Module registry, glossary, module-boundaries, ADR-016 updated
|
||||
- Progress and next-steps updated; handover complete
|
||||
- No business code, models, APIs, or migrations in this phase
|
||||
- Quality gates for documentation/architecture/manifest validation passed
|
||||
|
||||
- id: experience-11.0
|
||||
name: Experience Platform Foundation
|
||||
area: Experience
|
||||
description: Independent experience service scaffold, experience_db, health/capabilities/metrics, permissions experience.*, publish-only event shells, tenant isolation, audit/config shells, provider contracts only.
|
||||
status: complete
|
||||
dependencies:
|
||||
- experience-reg
|
||||
required_previous_phase: experience-reg
|
||||
required_documents:
|
||||
- docs/experience-roadmap.md
|
||||
- docs/phases/Experience/README.md
|
||||
- docs/architecture/adr/ADR-016.md
|
||||
- docs/phase-handover/phase-xp-reg.md
|
||||
- docs/experience-phase-11-0.md
|
||||
- docs/phase-handover/phase-11-0.md
|
||||
- docs/ai-framework/service-template.md
|
||||
- docs/ai-framework/phase-template.md
|
||||
required_services:
|
||||
- experience
|
||||
completion_criteria:
|
||||
- Service scaffold under backend/services/experience with API → Service → Repository → Model layering
|
||||
- Alembic initial migration for foundation tables only as scoped
|
||||
- Health, capabilities, metrics endpoints; permission tree experience.* documented
|
||||
- Tenant isolation + architecture + docs tests green
|
||||
- No page/theme/template engines beyond foundation shells
|
||||
- Quality gates passed; handover complete
|
||||
|
||||
- id: experience-11.1
|
||||
name: Sites & Page Resources
|
||||
area: Experience
|
||||
description: Sites, pages as resources, page types (landing, mini site, menu, bio, profile, event, campaign, portfolio, blog, QR, SEO, media, knowledge, …), lifecycle, permissions, events, APIs.
|
||||
status: complete
|
||||
dependencies:
|
||||
- experience-11.0
|
||||
required_previous_phase: experience-11.0
|
||||
required_documents:
|
||||
- docs/experience-roadmap.md
|
||||
- docs/phases/Experience/README.md
|
||||
- docs/architecture/adr/ADR-016.md
|
||||
- docs/experience-phase-11-1.md
|
||||
- docs/phase-handover/phase-11-1.md
|
||||
- docs/ai-framework/module-template.md
|
||||
required_services:
|
||||
- experience
|
||||
completion_criteria:
|
||||
- Site and page resource APIs + validators + tests green
|
||||
- No component versioning / theme / template engines yet
|
||||
|
||||
- id: experience-11.2
|
||||
name: Component Library & Versioning
|
||||
area: Experience
|
||||
description: Versioned components, component versions, composition shells, permissions, events, APIs.
|
||||
status: complete
|
||||
dependencies:
|
||||
- experience-11.1
|
||||
required_previous_phase: experience-11.1
|
||||
required_documents:
|
||||
- docs/experience-roadmap.md
|
||||
- docs/phases/Experience/README.md
|
||||
- docs/experience-phase-11-2.md
|
||||
- docs/phase-handover/phase-11-2.md
|
||||
required_services:
|
||||
- experience
|
||||
completion_criteria:
|
||||
- Component library with immutable versions; tenant isolation tests green
|
||||
- No theme/layout engine yet
|
||||
|
||||
- id: experience-11.3
|
||||
name: Theme & Layout Engine
|
||||
area: Experience
|
||||
description: Replaceable themes, dynamic layouts, directionality-ready shells.
|
||||
status: complete
|
||||
dependencies:
|
||||
- experience-11.2
|
||||
required_previous_phase: experience-11.2
|
||||
required_documents:
|
||||
- docs/experience-roadmap.md
|
||||
- docs/phases/Experience/README.md
|
||||
- docs/architecture/adr/ADR-008.md
|
||||
- docs/experience-phase-11-3.md
|
||||
- docs/phase-handover/phase-11-3.md
|
||||
required_services:
|
||||
- experience
|
||||
completion_criteria:
|
||||
- Theme and layout APIs + tests green
|
||||
- No template catalog engine yet
|
||||
|
||||
- id: experience-11.4
|
||||
name: Template System
|
||||
area: Experience
|
||||
description: Template catalog, instantiation from templates, page-type starter packs.
|
||||
status: planned
|
||||
dependencies:
|
||||
- experience-11.3
|
||||
required_previous_phase: experience-11.3
|
||||
required_documents:
|
||||
- docs/experience-roadmap.md
|
||||
- docs/phases/Experience/README.md
|
||||
required_services:
|
||||
- experience
|
||||
completion_criteria:
|
||||
- Template APIs + validators + tests green
|
||||
- No forms/surveys engine yet
|
||||
|
||||
- id: experience-11.5
|
||||
name: Localization, RTL/LTR & Media
|
||||
area: Experience
|
||||
description: Locales, RTL/LTR, media references via Storage only (no binaries in experience_db).
|
||||
status: planned
|
||||
dependencies:
|
||||
- experience-11.4
|
||||
required_previous_phase: experience-11.4
|
||||
required_documents:
|
||||
- docs/experience-roadmap.md
|
||||
- docs/phases/Experience/README.md
|
||||
required_services:
|
||||
- experience
|
||||
completion_criteria:
|
||||
- Localization and media-ref APIs + isolation tests green
|
||||
- No form/survey/appointment engines yet
|
||||
|
||||
- id: experience-11.6
|
||||
name: Forms, Surveys & Appointments
|
||||
area: Experience
|
||||
description: Forms, surveys, appointment page shells; submission/booking refs via APIs/events only.
|
||||
status: planned
|
||||
dependencies:
|
||||
- experience-11.5
|
||||
required_previous_phase: experience-11.5
|
||||
required_documents:
|
||||
- docs/experience-roadmap.md
|
||||
- docs/phases/Experience/README.md
|
||||
required_services:
|
||||
- experience
|
||||
completion_criteria:
|
||||
- Forms/surveys/appointment APIs + tests green
|
||||
- No deep CRM/scheduling ownership; refs only
|
||||
|
||||
- id: experience-11.7
|
||||
name: Publishing, Domains, SEO & PWA
|
||||
area: Experience
|
||||
description: Publish workflows, custom domain binding refs, SEO metadata/sitemap/robots shells, PWA readiness.
|
||||
status: planned
|
||||
dependencies:
|
||||
- experience-11.6
|
||||
required_previous_phase: experience-11.6
|
||||
required_documents:
|
||||
- docs/experience-roadmap.md
|
||||
- docs/phases/Experience/README.md
|
||||
- docs/architecture/adr/ADR-009.md
|
||||
required_services:
|
||||
- experience
|
||||
completion_criteria:
|
||||
- Publishing/SEO/PWA APIs + tests green
|
||||
- DNS/SSL edge remains Core/Nginx patterns; Experience stores binding refs
|
||||
|
||||
- id: experience-11.8
|
||||
name: Bundles, Licensing & Feature Toggles
|
||||
area: Experience
|
||||
description: Capability bundles (Mini Site, Menu, Bio Link, …), licensing shells, feature toggles coordinated with Core entitlement.
|
||||
status: planned
|
||||
dependencies:
|
||||
- experience-11.7
|
||||
required_previous_phase: experience-11.7
|
||||
required_documents:
|
||||
- docs/experience-roadmap.md
|
||||
- docs/phases/Experience/README.md
|
||||
required_services:
|
||||
- experience
|
||||
completion_criteria:
|
||||
- Bundles/toggles APIs + validators + tests green
|
||||
- No Core plan-engine ownership
|
||||
|
||||
- id: experience-11.9
|
||||
name: Consumer Integrations & Widgets
|
||||
area: Experience
|
||||
description: Consumer connector contracts for Hospitality/Marketplace/Sports/CRM/etc., embeddable widgets, Communication notify client.
|
||||
status: planned
|
||||
dependencies:
|
||||
- experience-11.8
|
||||
- communication-8
|
||||
required_previous_phase: experience-11.8
|
||||
required_documents:
|
||||
- docs/experience-roadmap.md
|
||||
- docs/architecture/adr/ADR-012.md
|
||||
- docs/architecture/adr/ADR-016.md
|
||||
required_services:
|
||||
- experience
|
||||
- communication
|
||||
completion_criteria:
|
||||
- Connector + widget contracts documented/tested
|
||||
- No SMS provider ownership; UI remains in frontend
|
||||
|
||||
- id: experience-11.10
|
||||
name: Analytics, AI Content & Enterprise Validation
|
||||
area: Experience
|
||||
description: Analytics shells, optional AI content hooks, full AI Framework validation — architecture, security, performance, docs, integration, tenant isolation, migration, API compatibility, self-heal until green.
|
||||
status: planned
|
||||
dependencies:
|
||||
- experience-11.9
|
||||
- ai-framework
|
||||
required_previous_phase: experience-11.9
|
||||
required_documents:
|
||||
- docs/experience-roadmap.md
|
||||
- docs/phases/Experience/README.md
|
||||
- docs/ai-framework/quality-gates.md
|
||||
- docs/ai-framework/phase-handover.md
|
||||
- docs/architecture/ai-architecture.md
|
||||
required_services:
|
||||
- experience
|
||||
completion_criteria:
|
||||
- All Experience quality gates passed
|
||||
- Core flows work when AI is off
|
||||
- Module registry version/phase updated; progress marks 11.10 complete
|
||||
- No undocumented public API/event/permission
|
||||
|
||||
# --- Hospitality (Phase 12.0+) — Torbat Food ---
|
||||
- id: hospitality-12.0
|
||||
name: Hospitality Platform Foundation
|
||||
area: Hospitality
|
||||
description: Independent hospitality service scaffold, hospitality_db, health/capabilities, bundle licensing + feature toggles, venue/menu/table shells, permissions hospitality.*, publish-only events, tenant isolation, audit shell. Commercial product Torbat Food.
|
||||
status: complete
|
||||
dependencies:
|
||||
- onboarding-4
|
||||
- ai-framework
|
||||
required_previous_phase: ai-framework
|
||||
required_documents:
|
||||
- docs/hospitality-roadmap.md
|
||||
- docs/phases/Hospitality/README.md
|
||||
- docs/architecture/adr/ADR-017.md
|
||||
- docs/hospitality-phase-12-0.md
|
||||
- docs/phase-handover/phase-12-0.md
|
||||
- docs/ai-framework/service-template.md
|
||||
- docs/ai-framework/phase-template.md
|
||||
required_services:
|
||||
- hospitality
|
||||
completion_criteria:
|
||||
- Service scaffold under backend/services/hospitality with API → Service → Repository → Model layering
|
||||
- Alembic initial migration for foundation tables only as scoped
|
||||
- Health + capabilities endpoints; permission tree hospitality.* documented
|
||||
- Bundle definitions + tenant bundle activation + feature toggles
|
||||
- Tenant isolation + architecture + docs tests green
|
||||
- Module registry, manifests, progress, handover updated
|
||||
- Quality gates passed; no POS/kitchen/ordering engines
|
||||
|
||||
- id: hospitality-12.1
|
||||
name: Digital Menu Catalog
|
||||
area: Hospitality
|
||||
description: Deep digital menu catalog — modifiers, allergens, availability windows, media refs, multi-language shells.
|
||||
status: complete
|
||||
dependencies:
|
||||
- hospitality-12.0
|
||||
required_previous_phase: hospitality-12.0
|
||||
required_documents:
|
||||
- docs/hospitality-roadmap.md
|
||||
- docs/phases/Hospitality/README.md
|
||||
- docs/hospitality-phase-12-1.md
|
||||
- docs/phase-handover/phase-12-1.md
|
||||
- docs/ai-framework/module-template.md
|
||||
required_services:
|
||||
- hospitality
|
||||
completion_criteria:
|
||||
- Digital menu catalog APIs + validators + tests green
|
||||
- No POS / kitchen / ordering engines yet
|
||||
- Module registry, manifests, progress, handover updated
|
||||
- Quality gates passed
|
||||
|
||||
- id: hospitality-12.2
|
||||
name: QR Menu & QR Ordering
|
||||
area: Hospitality
|
||||
description: QR Menu public surfaces and QR Ordering shells consuming the digital menu catalog.
|
||||
status: complete
|
||||
dependencies:
|
||||
- hospitality-12.1
|
||||
required_previous_phase: hospitality-12.1
|
||||
required_documents:
|
||||
- docs/hospitality-roadmap.md
|
||||
- docs/phases/Hospitality/README.md
|
||||
- docs/hospitality-phase-12-2.md
|
||||
- docs/phase-handover/phase-12-2.md
|
||||
- docs/ai-framework/module-template.md
|
||||
required_services:
|
||||
- hospitality
|
||||
completion_criteria:
|
||||
- QR Menu / Ordering APIs + validators + tests green
|
||||
- No POS / kitchen engines yet
|
||||
|
||||
- id: hospitality-12.3
|
||||
name: Table Service & Reservations
|
||||
area: Hospitality
|
||||
description: Reservation, table assignment, service request, and waitlist aggregates for dine-in table service workflows.
|
||||
status: complete
|
||||
dependencies:
|
||||
- hospitality-12.2
|
||||
required_previous_phase: hospitality-12.2
|
||||
required_documents:
|
||||
- docs/hospitality-roadmap.md
|
||||
- docs/phases/Hospitality/README.md
|
||||
- docs/hospitality-phase-12-3.md
|
||||
- docs/phase-handover/phase-12-3.md
|
||||
- docs/ai-framework/module-template.md
|
||||
required_services:
|
||||
- hospitality
|
||||
completion_criteria:
|
||||
- Reservation / table assignment / service request / waitlist APIs + validators + tests green
|
||||
- No POS / kitchen / payment engines
|
||||
|
||||
- id: hospitality-12.4
|
||||
name: POS Lite
|
||||
area: Hospitality
|
||||
description: Lightweight point-of-sale shell for small venue formats.
|
||||
status: complete
|
||||
dependencies:
|
||||
- hospitality-12.3
|
||||
required_previous_phase: hospitality-12.3
|
||||
required_documents:
|
||||
- docs/hospitality-roadmap.md
|
||||
- docs/phases/Hospitality/README.md
|
||||
- docs/hospitality-phase-12-4.md
|
||||
- docs/phase-handover/phase-12-4.md
|
||||
required_services:
|
||||
- hospitality
|
||||
completion_criteria:
|
||||
- POS Lite APIs + validators + tests green
|
||||
|
||||
- id: hospitality-12.5
|
||||
name: POS Pro
|
||||
area: Hospitality
|
||||
description: Full-featured point-of-sale engine for larger venue formats.
|
||||
status: complete
|
||||
dependencies:
|
||||
- hospitality-12.4
|
||||
required_previous_phase: hospitality-12.4
|
||||
required_documents:
|
||||
- docs/hospitality-roadmap.md
|
||||
- docs/phases/Hospitality/README.md
|
||||
- docs/hospitality-phase-12-5.md
|
||||
- docs/phase-handover/phase-12-5.md
|
||||
required_services:
|
||||
- hospitality
|
||||
completion_criteria:
|
||||
- POS Pro APIs + validators + tests green
|
||||
|
||||
- id: hospitality-12.6
|
||||
name: Kitchen
|
||||
area: Hospitality
|
||||
description: Kitchen display / ticket routing engine consuming POS and ordering sessions.
|
||||
status: complete
|
||||
dependencies:
|
||||
- hospitality-12.5
|
||||
required_previous_phase: hospitality-12.5
|
||||
required_documents:
|
||||
- docs/hospitality-roadmap.md
|
||||
- docs/phases/Hospitality/README.md
|
||||
- docs/hospitality-phase-12-6.md
|
||||
- docs/phase-handover/phase-12-6.md
|
||||
required_services:
|
||||
- hospitality
|
||||
completion_criteria:
|
||||
- Kitchen ticket APIs + validators + tests green
|
||||
|
||||
- id: hospitality-12.7
|
||||
name: Delivery / Accounting / CRM / Loyalty / Communication / Website Connectors
|
||||
area: Hospitality
|
||||
description: Outbound connector contracts integrating Delivery, Accounting, CRM, Loyalty, Communication, and Website Builder platforms via API/Events only.
|
||||
status: complete
|
||||
dependencies:
|
||||
- hospitality-12.6
|
||||
required_previous_phase: hospitality-12.6
|
||||
required_documents:
|
||||
- docs/hospitality-roadmap.md
|
||||
- docs/phases/Hospitality/README.md
|
||||
- docs/hospitality-phase-12-7.md
|
||||
- docs/phase-handover/phase-12-7.md
|
||||
required_services:
|
||||
- hospitality
|
||||
completion_criteria:
|
||||
- Connector contracts + tests green; no direct DB coupling
|
||||
|
||||
- id: hospitality-12.8
|
||||
name: Analytics
|
||||
area: Hospitality
|
||||
description: Analytics shells and reporting surfaces for hospitality operations.
|
||||
status: complete
|
||||
dependencies:
|
||||
- hospitality-12.7
|
||||
required_previous_phase: hospitality-12.7
|
||||
required_documents:
|
||||
- docs/hospitality-roadmap.md
|
||||
- docs/phases/Hospitality/README.md
|
||||
- docs/hospitality-phase-12-8.md
|
||||
- docs/phase-handover/phase-12-8.md
|
||||
required_services:
|
||||
- hospitality
|
||||
completion_criteria:
|
||||
- Analytics APIs + tests green
|
||||
|
||||
- id: hospitality-12.9
|
||||
name: AI Assistant
|
||||
area: Hospitality
|
||||
description: AI assistant contracts for hospitality operations (optional, contract-only).
|
||||
status: planned
|
||||
dependencies:
|
||||
- hospitality-12.8
|
||||
required_previous_phase: hospitality-12.8
|
||||
required_documents:
|
||||
- docs/hospitality-roadmap.md
|
||||
- docs/phases/Hospitality/README.md
|
||||
required_services:
|
||||
- hospitality
|
||||
completion_criteria:
|
||||
- AI assistant contracts + tests green
|
||||
|
||||
- id: hospitality-12.10
|
||||
name: Enterprise Validation
|
||||
area: Hospitality
|
||||
description: Final enterprise-grade validation pass across the Hospitality Platform prior to GA.
|
||||
status: planned
|
||||
dependencies:
|
||||
- hospitality-12.9
|
||||
required_previous_phase: hospitality-12.9
|
||||
required_documents:
|
||||
- docs/hospitality-roadmap.md
|
||||
- docs/phases/Hospitality/README.md
|
||||
required_services:
|
||||
- hospitality
|
||||
completion_criteria:
|
||||
- Full quality gate suite green across all Hospitality phases
|
||||
|
||||
# Historical alias — superseded by hospitality-12.0
|
||||
- id: restaurant-foundation
|
||||
name: Restaurant / Cafe Foundation (superseded)
|
||||
area: Restaurant
|
||||
status: complete
|
||||
notes: Superseded by hospitality-12.0 (Hospitality Platform / Torbat Food). Kept for manifest continuity.
|
||||
dependencies:
|
||||
- onboarding-4
|
||||
- ai-framework
|
||||
required_documents:
|
||||
- docs/hospitality-phase-12-0.md
|
||||
- docs/phases/Restaurant/README.md
|
||||
- docs/ai-framework/service-template.md
|
||||
required_services:
|
||||
- restaurant
|
||||
- hospitality
|
||||
|
||||
# --- Marketplace / Ecommerce ---
|
||||
- id: ecommerce-foundation
|
||||
@ -929,4 +1571,4 @@ phases:
|
||||
- docs/phases/Future/README.md
|
||||
- docs/roadmap.md
|
||||
required_services: []
|
||||
notes: Catch-all for website_builder, live_chat, smart_messenger, notification, file_storage, link_shortener, and unlisted future modules.
|
||||
notes: Catch-all for live_chat, smart_messenger, notification, file_storage, link_shortener, historical website_builder scaffold, and unlisted future modules. Prefer experience (ADR-016) for page/site work.
|
||||
|
||||
624
docs/ai-framework/project-index.yaml
Normal file
624
docs/ai-framework/project-index.yaml
Normal 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
|
||||
@ -125,22 +125,32 @@ services:
|
||||
- loyalty.tier.*
|
||||
- loyalty.member.*
|
||||
- loyalty.point_account.*
|
||||
- loyalty.points.*
|
||||
- loyalty.reward.*
|
||||
- loyalty.campaign.*
|
||||
- loyalty.referral.*
|
||||
- loyalty.wallet.*
|
||||
health_endpoint: /health
|
||||
documentation:
|
||||
- docs/loyalty-phase-7-0.md
|
||||
- docs/loyalty-phase-7-1.md
|
||||
- docs/phase-handover/phase-7-1.md
|
||||
- docs/loyalty-phase-7-2.md
|
||||
- docs/loyalty-phase-7-3.md
|
||||
- docs/loyalty-phase-7-4.md
|
||||
- docs/loyalty-phase-7-5.md
|
||||
- docs/loyalty-phase-7-6.md
|
||||
- docs/phase-handover/phase-7-6.md
|
||||
- docs/service-snapshots/loyalty.yaml
|
||||
- docs/phases/Loyalty/README.md
|
||||
- docs/architecture/adr/ADR-011.md
|
||||
current_version: 0.7.1.0
|
||||
current_phase: loyalty-7.1
|
||||
current_version: 0.7.6.0
|
||||
current_phase: loyalty-7.6
|
||||
status: active
|
||||
api_port: 8004
|
||||
|
||||
- id: communication
|
||||
name: Enterprise Communication Platform
|
||||
description: Independent shared messaging platform (Torbat Communication). Production Ready for SMS MVP; future channels documented in communication-roadmap.md only.
|
||||
owner: Platform
|
||||
path: backend/services/communication
|
||||
database: communication_db
|
||||
@ -158,8 +168,12 @@ services:
|
||||
health_endpoint: /health
|
||||
documentation:
|
||||
- docs/communication-phase-8.md
|
||||
- docs/communication-phase-8-audit.md
|
||||
- docs/communication-roadmap.md
|
||||
- docs/phase-handover/phase-8.md
|
||||
- docs/service-snapshots/communication.yaml
|
||||
- docs/architecture/adr/ADR-012.md
|
||||
current_version: 0.8.10.0
|
||||
current_version: 0.8.10.1
|
||||
current_phase: communication-8
|
||||
status: active
|
||||
api_port: 8005
|
||||
@ -241,12 +255,17 @@ services:
|
||||
- docs/sports-center-phase-9-0.md
|
||||
- docs/sports-center-phase-9-1.md
|
||||
- docs/sports-center-phase-9-2.md
|
||||
- docs/phase-handover/phase-9-2.md
|
||||
- docs/sports-center-phase-9-3.md
|
||||
- docs/sports-center-phase-9-4.md
|
||||
- docs/sports-center-phase-9-5.md
|
||||
- docs/sports-center-phase-9-6.md
|
||||
- docs/sports-center-phase-9-7.md
|
||||
- docs/phase-handover/phase-9-7.md
|
||||
- docs/phases/SportsCenter/README.md
|
||||
- docs/architecture/adr/ADR-014.md
|
||||
- docs/module-registry.md
|
||||
current_version: 0.9.2.0
|
||||
current_phase: sports-center-9.2
|
||||
current_version: 0.9.7.0
|
||||
current_phase: sports-center-9.7
|
||||
current_status: active
|
||||
status: active
|
||||
future_modules:
|
||||
@ -294,6 +313,12 @@ services:
|
||||
- loyalty
|
||||
- communication
|
||||
events:
|
||||
- delivery.organization.*
|
||||
- delivery.hub.*
|
||||
- delivery.external_provider.*
|
||||
- delivery.routing_engine.*
|
||||
- delivery.configuration.*
|
||||
- delivery.setting.*
|
||||
- delivery.driver.*
|
||||
- delivery.fleet.*
|
||||
- delivery.vehicle.*
|
||||
@ -308,25 +333,36 @@ services:
|
||||
- /health
|
||||
- /capabilities
|
||||
- /metrics
|
||||
- /api/v1 (phase-scoped resources)
|
||||
- /api/v1/organizations
|
||||
- /api/v1/hubs
|
||||
- /api/v1/external-providers
|
||||
- /api/v1/routing-engines
|
||||
- /api/v1/configurations
|
||||
- /api/v1/settings
|
||||
- /api/v1/audit
|
||||
- /api/v1/drivers
|
||||
- /api/v1/permissions/catalog
|
||||
internal_apis:
|
||||
- service-to-service merchant connector and settlement refs (token-gated; not public)
|
||||
tenant_aware: true
|
||||
audit_enabled: true
|
||||
documentation:
|
||||
- docs/delivery-roadmap.md
|
||||
- docs/delivery-phase-10-0.md
|
||||
- docs/delivery-phase-10-1.md
|
||||
- docs/phases/Delivery/README.md
|
||||
- docs/phase-handover/phase-dp-reg.md
|
||||
- docs/phase-handover/phase-10-0.md
|
||||
- docs/phase-handover/phase-10-1.md
|
||||
- docs/architecture/adr/ADR-015.md
|
||||
- docs/module-registry.md
|
||||
current_version: 0.0.0
|
||||
current_phase: delivery-reg
|
||||
current_status: registered
|
||||
status: planned
|
||||
current_version: 0.10.1.0
|
||||
current_phase: delivery-10.1
|
||||
current_status: active
|
||||
status: active
|
||||
api_port: 8007
|
||||
commercial_product: Torbat Driver
|
||||
future_modules:
|
||||
- drivers
|
||||
- fleet
|
||||
- vehicle_types
|
||||
- vehicles
|
||||
@ -351,24 +387,300 @@ services:
|
||||
- ai_hooks
|
||||
- external_providers
|
||||
|
||||
- id: restaurant
|
||||
name: Restaurant / Cafe
|
||||
owner: TBD
|
||||
path: backend/services/restaurant
|
||||
database: restaurant_db
|
||||
- id: experience
|
||||
name: Enterprise Experience Platform
|
||||
description: Independent multi-tenant experience platform (Torbat Pages) — sites, pages-as-resources, versioned components, themes, layouts, templates, localization, forms, SEO/PWA, bundles, widgets. Consumed by Hospitality, Marketplace, Sports Center, CRM, and future verticals via API/Events only.
|
||||
owner: Platform
|
||||
path: backend/services/experience
|
||||
database: experience_db
|
||||
api_prefix: /api/v1
|
||||
permission_prefix: restaurant.*
|
||||
permission_prefix: experience.*
|
||||
health_endpoint: /health
|
||||
configuration:
|
||||
- EXPERIENCE_DATABASE_URL
|
||||
- AUTH_REQUIRED
|
||||
- tenant resolution via X-Tenant-ID / shared middleware
|
||||
- entitlement feature keys experience.*
|
||||
dependencies:
|
||||
- core-platform
|
||||
- shared-lib
|
||||
optional_dependencies:
|
||||
- file_storage
|
||||
- crm
|
||||
- loyalty
|
||||
- communication
|
||||
- accounting
|
||||
- sports_center
|
||||
- delivery
|
||||
- ai_assistant
|
||||
- automation
|
||||
events:
|
||||
- order.*
|
||||
- experience.workspace.*
|
||||
- experience.locale_profile.*
|
||||
- experience.external_provider.*
|
||||
- experience.render_engine.*
|
||||
- experience.configuration.*
|
||||
- experience.setting.*
|
||||
- experience.site.*
|
||||
- experience.page.*
|
||||
- experience.site_domain.*
|
||||
- experience.component.*
|
||||
- experience.component_version.*
|
||||
- experience.page_component.*
|
||||
- experience.theme.*
|
||||
- experience.layout.*
|
||||
- experience.template.*
|
||||
- experience.form.*
|
||||
- experience.publish.*
|
||||
- experience.domain.*
|
||||
- experience.bundle.*
|
||||
- experience.widget.*
|
||||
public_apis:
|
||||
- /health
|
||||
- /capabilities
|
||||
- /metrics
|
||||
- /api/v1/workspaces
|
||||
- /api/v1/locale-profiles
|
||||
- /api/v1/external-providers
|
||||
- /api/v1/render-engines
|
||||
- /api/v1/configurations
|
||||
- /api/v1/settings
|
||||
- /api/v1/audit
|
||||
- /api/v1/sites
|
||||
- /api/v1/pages
|
||||
- /api/v1/site-domains
|
||||
- /api/v1/components
|
||||
- /api/v1/component-versions
|
||||
- /api/v1/page-component-placements
|
||||
- /api/v1/themes
|
||||
- /api/v1/layouts
|
||||
- /api/v1/site-theme-assignments
|
||||
- /api/v1/page-layout-assignments
|
||||
internal_apis:
|
||||
- service-to-service consumer connector refs (token-gated; not public)
|
||||
tenant_aware: true
|
||||
audit_enabled: true
|
||||
documentation:
|
||||
- docs/experience-roadmap.md
|
||||
- docs/experience-phase-xp-reg.md
|
||||
- docs/experience-phase-11-0.md
|
||||
- docs/experience-phase-11-1.md
|
||||
- docs/experience-phase-11-2.md
|
||||
- docs/experience-phase-11-3.md
|
||||
- docs/phases/Experience/README.md
|
||||
- docs/phase-handover/phase-xp-reg.md
|
||||
- docs/phase-handover/phase-11-0.md
|
||||
- docs/phase-handover/phase-11-1.md
|
||||
- docs/phase-handover/phase-11-2.md
|
||||
- docs/phase-handover/phase-11-3.md
|
||||
- docs/architecture/adr/ADR-016.md
|
||||
- docs/module-registry.md
|
||||
current_version: 0.11.3.0
|
||||
current_phase: experience-11.3
|
||||
current_status: active
|
||||
status: active
|
||||
api_port: 8008
|
||||
commercial_product: Torbat Pages
|
||||
future_modules:
|
||||
- sites
|
||||
- pages
|
||||
- page_types
|
||||
- components
|
||||
- component_versions
|
||||
- themes
|
||||
- layouts
|
||||
- templates
|
||||
- locales
|
||||
- media_refs
|
||||
- forms
|
||||
- surveys
|
||||
- appointments
|
||||
- publishing
|
||||
- domains
|
||||
- seo
|
||||
- pwa
|
||||
- bundles
|
||||
- feature_toggles
|
||||
- widgets
|
||||
- consumer_connectors
|
||||
- analytics
|
||||
- ai_hooks
|
||||
- audit
|
||||
- configuration
|
||||
|
||||
- id: hospitality
|
||||
name: Hospitality Platform
|
||||
description: Independent multi-tenant F&B / hospitality operations platform (Torbat Food) — venues, digital menu catalog, tables, bundle licensing, feature toggles; future QR ordering/POS/kitchen/reservation/connectors. Integrates Accounting, CRM, Loyalty, Communication, Delivery, Website Builder, AI via API/Events only.
|
||||
owner: Platform
|
||||
path: backend/services/hospitality
|
||||
database: hospitality_db
|
||||
api_prefix: /api/v1
|
||||
permission_prefix: hospitality.*
|
||||
health_endpoint: /health
|
||||
configuration:
|
||||
- HOSPITALITY_DATABASE_URL
|
||||
- AUTH_REQUIRED
|
||||
- tenant resolution via X-Tenant-ID / shared middleware
|
||||
- entitlement / bundle feature keys hospitality.*
|
||||
dependencies:
|
||||
- core-platform
|
||||
- shared-lib
|
||||
optional_dependencies:
|
||||
- accounting
|
||||
- crm
|
||||
- loyalty
|
||||
- communication
|
||||
- delivery
|
||||
- website_builder
|
||||
events:
|
||||
- hospitality.venue.*
|
||||
- hospitality.branch.*
|
||||
- hospitality.menu.*
|
||||
- hospitality.menu_category.*
|
||||
- hospitality.menu_item.*
|
||||
- hospitality.allergen.*
|
||||
- hospitality.modifier_group.*
|
||||
- hospitality.modifier_option.*
|
||||
- hospitality.menu_item_modifier.*
|
||||
- hospitality.menu_item_allergen.*
|
||||
- hospitality.availability_window.*
|
||||
- hospitality.menu_media_ref.*
|
||||
- hospitality.menu_localization.*
|
||||
- hospitality.dining_area.*
|
||||
- hospitality.table.*
|
||||
- hospitality.bundle_definition.*
|
||||
- hospitality.tenant_bundle.*
|
||||
- hospitality.feature_toggle.*
|
||||
- hospitality.configuration.*
|
||||
- hospitality.setting.*
|
||||
- hospitality.qr_code.*
|
||||
- hospitality.qr_menu_session.*
|
||||
- hospitality.qr_ordering_session.*
|
||||
- hospitality.cart.*
|
||||
- hospitality.cart_line.*
|
||||
- hospitality.reservation.*
|
||||
- hospitality.table_assignment.*
|
||||
- hospitality.service_request.*
|
||||
- hospitality.waitlist_entry.*
|
||||
- hospitality.pos_register.*
|
||||
- hospitality.pos_shift.*
|
||||
- hospitality.pos_ticket.*
|
||||
- hospitality.pos_ticket_line.*
|
||||
- hospitality.pos_payment.*
|
||||
- hospitality.pos_discount.*
|
||||
- hospitality.pos_tax_line.*
|
||||
- hospitality.pos_floor_plan.*
|
||||
- hospitality.pos_station.*
|
||||
- hospitality.kitchen_station.*
|
||||
- hospitality.kitchen_ticket.*
|
||||
- hospitality.kitchen_ticket_item.*
|
||||
- hospitality.connector_registration.*
|
||||
- hospitality.connector_dispatch.*
|
||||
- hospitality.analytics_report_definition.*
|
||||
- hospitality.analytics_snapshot.*
|
||||
public_apis:
|
||||
- /health
|
||||
- /capabilities
|
||||
- /api/v1/venues
|
||||
- /api/v1/branches
|
||||
- /api/v1/dining-areas
|
||||
- /api/v1/tables
|
||||
- /api/v1/menus
|
||||
- /api/v1/menu-categories
|
||||
- /api/v1/menu-items
|
||||
- /api/v1/allergens
|
||||
- /api/v1/modifier-groups
|
||||
- /api/v1/modifier-options
|
||||
- /api/v1/menu-item-modifiers
|
||||
- /api/v1/menu-item-allergens
|
||||
- /api/v1/availability-windows
|
||||
- /api/v1/menu-media-refs
|
||||
- /api/v1/menu-localizations
|
||||
- /api/v1/qr-codes
|
||||
- /api/v1/qr-menu-sessions
|
||||
- /api/v1/qr-ordering-sessions
|
||||
- /api/v1/carts
|
||||
- /api/v1/cart-lines
|
||||
- /api/v1/reservations
|
||||
- /api/v1/table-assignments
|
||||
- /api/v1/service-requests
|
||||
- /api/v1/waitlist
|
||||
- /api/v1/bundle-definitions
|
||||
- /api/v1/tenant-bundles
|
||||
- /api/v1/feature-toggles
|
||||
- /api/v1/roles
|
||||
- /api/v1/permissions
|
||||
- /api/v1/configurations
|
||||
- /api/v1/events
|
||||
- /api/v1/settings
|
||||
internal_apis:
|
||||
- service-to-service refs for Accounting / CRM / Loyalty / Communication / Delivery / Website (token-gated; not public)
|
||||
tenant_aware: true
|
||||
audit_enabled: true
|
||||
documentation:
|
||||
- docs/hospitality-roadmap.md
|
||||
- docs/hospitality-phase-12-0.md
|
||||
- docs/hospitality-phase-12-1.md
|
||||
- docs/hospitality-phase-12-2.md
|
||||
- docs/hospitality-phase-12-3.md
|
||||
- docs/hospitality-phase-12-4.md
|
||||
- docs/hospitality-phase-12-5.md
|
||||
- docs/hospitality-phase-12-6.md
|
||||
- docs/hospitality-phase-12-7.md
|
||||
- docs/hospitality-phase-12-8.md
|
||||
- docs/phase-handover/phase-12-0.md
|
||||
- docs/phase-handover/phase-12-1.md
|
||||
- docs/phase-handover/phase-12-2.md
|
||||
- docs/phase-handover/phase-12-3.md
|
||||
- docs/phase-handover/phase-12-4.md
|
||||
- docs/phase-handover/phase-12-5.md
|
||||
- docs/phase-handover/phase-12-6.md
|
||||
- docs/phase-handover/phase-12-7.md
|
||||
- docs/phase-handover/phase-12-8.md
|
||||
- docs/phases/Hospitality/README.md
|
||||
- docs/architecture/adr/ADR-017.md
|
||||
- docs/module-registry.md
|
||||
current_version: 0.12.8.0
|
||||
current_phase: hospitality-12.8
|
||||
current_status: active
|
||||
status: active
|
||||
api_port: 8009
|
||||
commercial_product: Torbat Food
|
||||
future_modules:
|
||||
- pos_lite
|
||||
- pos_pro
|
||||
- kitchen
|
||||
- delivery_connector
|
||||
- accounting_connector
|
||||
- crm_connector
|
||||
- loyalty_connector
|
||||
- communication_connector
|
||||
- website_connector
|
||||
- analytics
|
||||
- ai_assistant
|
||||
|
||||
- id: restaurant
|
||||
name: Restaurant / Cafe (historical scaffold)
|
||||
owner: Platform
|
||||
path: backend/services/restaurant
|
||||
database: hospitality_db
|
||||
api_prefix: /api/v1
|
||||
permission_prefix: hospitality.*
|
||||
dependencies:
|
||||
- hospitality
|
||||
events:
|
||||
- hospitality.*
|
||||
health_endpoint: /health
|
||||
documentation:
|
||||
- docs/phases/Restaurant/README.md
|
||||
current_version: 0.0.0
|
||||
current_phase: restaurant-foundation
|
||||
status: scaffolded
|
||||
- docs/hospitality-phase-12-0.md
|
||||
- docs/hospitality-phase-12-1.md
|
||||
- docs/hospitality-phase-12-2.md
|
||||
- docs/hospitality-phase-12-3.md
|
||||
current_version: 0.12.3.0
|
||||
current_phase: hospitality-12.3
|
||||
status: superseded
|
||||
notes: Pointer only — runtime is hospitality service (ADR-017).
|
||||
|
||||
- id: ecommerce
|
||||
name: Ecommerce
|
||||
@ -524,9 +836,11 @@ services:
|
||||
health_endpoint: /health
|
||||
documentation:
|
||||
- docs/module-registry.md
|
||||
- docs/architecture/adr/ADR-016.md
|
||||
current_version: 0.0.0
|
||||
current_phase: future-services
|
||||
status: scaffolded
|
||||
notes: Prefer experience service (ADR-016 / Torbat Pages) for new page/site work. Historical scaffold only.
|
||||
|
||||
- id: live_chat
|
||||
name: Live Chat
|
||||
|
||||
101
docs/loyalty-phase-7-2.md
Normal file
101
docs/loyalty-phase-7-2.md
Normal 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.5–7.9)
|
||||
|
||||
## Ledger Rules
|
||||
|
||||
| Rule | Detail |
|
||||
| --- | --- |
|
||||
| Append-only | No UPDATE/DELETE of ledger rows |
|
||||
| Signed amounts | Earn/adjust+ positive; redeem/expire/adjust− negative |
|
||||
| Open accounts only | Frozen/closed accounts reject mutations |
|
||||
| Idempotency | Same `(tenant_id, idempotency_key)` returns existing entry |
|
||||
| Balance | Computed from ledger; never a mutable column on `PointAccount` |
|
||||
|
||||
## Models
|
||||
|
||||
| Entity | Soft delete | Audit | Tenant |
|
||||
| --- | --- | --- | --- |
|
||||
| PointLedgerEntry | No (append-only) | Via fields + audit log | Yes |
|
||||
|
||||
## APIs
|
||||
|
||||
| Method | Path | Permission |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/point-accounts/{id}/balance` | `loyalty.points.view` |
|
||||
| GET | `/api/v1/point-accounts/{id}/ledger` | `loyalty.points.view` |
|
||||
| POST | `/api/v1/point-accounts/{id}/earn` | `loyalty.points.earn` |
|
||||
| POST | `/api/v1/point-accounts/{id}/redeem` | `loyalty.points.redeem` |
|
||||
| POST | `/api/v1/point-accounts/{id}/adjust` | `loyalty.points.adjust` |
|
||||
| POST | `/api/v1/point-accounts/{id}/expire-points` | `loyalty.points.expire` |
|
||||
|
||||
## Events
|
||||
|
||||
| Event | When |
|
||||
| --- | --- |
|
||||
| `loyalty.points.earned` | Earn |
|
||||
| `loyalty.points.redeemed` | Redeem |
|
||||
| `loyalty.points.adjusted` | Adjust |
|
||||
| `loyalty.points.expired` | Expire points |
|
||||
|
||||
## Migration
|
||||
|
||||
| Item | Detail |
|
||||
| --- | --- |
|
||||
| Alembic | `0003_phase_72_points` (down_revision `0002_phase_71_membership`) |
|
||||
| Breaking | None — additive table |
|
||||
| Backfill | None required |
|
||||
|
||||
## Tests
|
||||
|
||||
Command: `cd backend/services/loyalty && pytest -q` → **61 passed**
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- No automatic expire scheduler (API-driven expire only)
|
||||
- No FIFO lot tracking for expiration (bulk expire by amount)
|
||||
- Transfer still does not migrate ledger entries (explicit follow-up if needed)
|
||||
|
||||
## Next Phase
|
||||
|
||||
Phase 7.3 — Rewards Engine
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [Handover](phase-handover/phase-7-2.md)
|
||||
- [Phase 7.1](loyalty-phase-7-1.md)
|
||||
- [ADR-011](architecture/adr/ADR-011.md)
|
||||
- [Service Snapshot](service-snapshots/loyalty.yaml)
|
||||
- [Module Registry](module-registry.md#loyalty)
|
||||
92
docs/loyalty-phase-7-3.md
Normal file
92
docs/loyalty-phase-7-3.md
Normal 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.5–7.9)
|
||||
|
||||
## Redemption Rules
|
||||
|
||||
| Rule | Detail |
|
||||
| --- | --- |
|
||||
| Active reward only | Status must be `active` |
|
||||
| Stock | `redeemed_count < stock_limit` when limit set |
|
||||
| Per-member cap | Non-cancelled count ≤ `max_per_member` when set |
|
||||
| Member | Must be `active` |
|
||||
| Points | Debit via ledger; free (`points_cost=0`) skips ledger |
|
||||
| Cancel | Only from `pending`; refunds points; decrements stock counter |
|
||||
| Fulfill | Only from `pending` |
|
||||
|
||||
## APIs
|
||||
|
||||
| Method | Path | Permission |
|
||||
| --- | --- | --- |
|
||||
| POST | `/api/v1/rewards/{id}/redeem` | `loyalty.rewards.redeem` |
|
||||
| GET | `/api/v1/rewards/{id}/redemptions` | `loyalty.redemptions.view` |
|
||||
| GET | `/api/v1/redemptions` | `loyalty.redemptions.view` |
|
||||
| GET | `/api/v1/redemptions/{id}` | `loyalty.redemptions.view` |
|
||||
| POST | `/api/v1/redemptions/{id}/fulfill` | `loyalty.rewards.fulfill` |
|
||||
| POST | `/api/v1/redemptions/{id}/cancel` | `loyalty.rewards.cancel` |
|
||||
|
||||
## Events
|
||||
|
||||
| Event | When |
|
||||
| --- | --- |
|
||||
| `loyalty.reward.redeemed` | Redeem (points deducted) |
|
||||
| `loyalty.reward.fulfilled` | Fulfill |
|
||||
| `loyalty.reward.redemption_cancelled` | Cancel + refund |
|
||||
|
||||
## Migration
|
||||
|
||||
| Item | Detail |
|
||||
| --- | --- |
|
||||
| Alembic | `0004_phase_73_rewards` (down_revision `0003_phase_72_points`) |
|
||||
| Breaking | None — additive columns + table |
|
||||
|
||||
## Tests
|
||||
|
||||
Command: `cd backend/services/loyalty && pytest -q` → **72 passed**
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- No automatic redemption expiry worker
|
||||
- No partner fulfillment webhooks (7.9)
|
||||
- Campaign-driven rewards deferred to 7.4
|
||||
|
||||
## Next Phase
|
||||
|
||||
Phase 7.4 — Campaign Engine
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [Handover](phase-handover/phase-7-3.md)
|
||||
- [Phase 7.2](loyalty-phase-7-2.md)
|
||||
- [Service Snapshot](service-snapshots/loyalty.yaml)
|
||||
- [ADR-011](architecture/adr/ADR-011.md)
|
||||
61
docs/loyalty-phase-7-4.md
Normal file
61
docs/loyalty-phase-7-4.md
Normal 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
35
docs/loyalty-phase-7-5.md
Normal 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
100
docs/loyalty-phase-7-6.md
Normal 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)
|
||||
@ -169,27 +169,27 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
|
||||
| Name | Enterprise Loyalty Platform |
|
||||
| Description | Shared loyalty: programs, members, tiers, point accounts, rewards, campaigns; reusable by all business modules |
|
||||
| Owner | Platform |
|
||||
| Status | Active (Phase 7.1 Membership Engine) |
|
||||
| Status | Active (Phase 7.6 Wallet) |
|
||||
| Dependencies | Core entitlement (feature-key gate deferred platform-wide; JWT + tenant header enforced) |
|
||||
| Internal Dependencies | None — independent of CRM |
|
||||
| External Dependencies | Platform providers via contracts only (Notification, Analytics, Customer360, AI, ModuleIntegration, CRM, Communication, FileStorage) |
|
||||
| Database Ownership | Sole owner |
|
||||
| Database | `loyalty_db` |
|
||||
| API Prefix | `/api/v1` (service on port 8004) |
|
||||
| Permission Prefix | `loyalty.*`, `loyalty.programs.*`, `loyalty.tiers.*`, `loyalty.members.*`, `loyalty.point_accounts.*`, `loyalty.rewards.*`, `loyalty.campaigns.*`, `loyalty.audit.*` |
|
||||
| Events | `loyalty.program.*`, `loyalty.tier.*`, `loyalty.member.*`, `loyalty.point_account.*`, `loyalty.reward.*`, `loyalty.campaign.*` |
|
||||
| Permission Prefix | `loyalty.*`, `loyalty.programs.*`, `loyalty.tiers.*`, `loyalty.members.*`, `loyalty.point_accounts.*`, `loyalty.points.*`, `loyalty.rewards.*`, `loyalty.redemptions.*`, `loyalty.campaigns.*`, `loyalty.referral_programs.*`, `loyalty.referral_codes.*`, `loyalty.referrals.*`, `loyalty.wallets.*`, `loyalty.audit.*` |
|
||||
| Events | `loyalty.program.*`, `loyalty.tier.*`, `loyalty.member.*`, `loyalty.point_account.*`, `loyalty.points.*`, `loyalty.reward.*`, `loyalty.campaign.*`, `loyalty.referral.*`, `loyalty.wallet.*` |
|
||||
| Event Producers | Loyalty (transactional outbox) |
|
||||
| Event Consumers | Future business modules / Customer360 (not consumed in 7.1) |
|
||||
| Event Consumers | Future business modules / Customer360 (not consumed in 7.6) |
|
||||
| Provider Dependencies | Contracts only — no implementations |
|
||||
| AI Dependencies | Optional via `AIProvider` contract only |
|
||||
| Documentation | [loyalty-phase-7-0.md](loyalty-phase-7-0.md), [loyalty-phase-7-1.md](loyalty-phase-7-1.md), [phase-handover/phase-7-1.md](phase-handover/phase-7-1.md), [phases/Loyalty](phases/Loyalty/README.md), service README |
|
||||
| Current Phase | 7.1 complete (Membership Engine); 7.2 not started |
|
||||
| Version | 0.7.1.0 |
|
||||
| Documentation | [loyalty-phase-7-0.md](loyalty-phase-7-0.md) … [loyalty-phase-7-6.md](loyalty-phase-7-6.md), [phase-handover/phase-7-6.md](phase-handover/phase-7-6.md), [service-snapshots/loyalty.yaml](service-snapshots/loyalty.yaml), [phases/Loyalty](phases/Loyalty/README.md) |
|
||||
| Current Phase | 7.6 complete (Wallet); 7.7 next |
|
||||
| Version | 0.7.6.0 |
|
||||
| Version Compatibility | Designed for Core entitlement; runtime feature-check deferred |
|
||||
| Migration Version | `0002_phase_71_membership` |
|
||||
| Migration Version | `0007_phase_76_wallet` |
|
||||
| Tenant Aware | Yes (required) |
|
||||
| Permission Tree | `loyalty.*` (view/manage inheritance supported) |
|
||||
| Future Plans | Phases 7.2–7.10 (points ledger, rewards, campaigns, coupon/voucher, wallet/gift, partner, integrations, AI/analytics, production readiness) |
|
||||
| Future Plans | Phases 7.7–7.10 (gift cards, analytics, partner APIs, final review) |
|
||||
|
||||
---
|
||||
|
||||
@ -197,10 +197,10 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Name | Enterprise Communication Platform |
|
||||
| Name | Enterprise Communication Platform (Torbat Communication) |
|
||||
| Description | Independent shared messaging: providers, router, SMS, templates, dynamic contacts, queue, delivery tracking, OTP, webhooks, monitoring |
|
||||
| Owner | Platform |
|
||||
| Status | Active (Phase 8.0–8.10 complete) |
|
||||
| Status | **Production Ready (SMS MVP)** — Phase 8.0–8.10 complete; feature-complete for current scope |
|
||||
| Dependencies | Core entitlement (optional gate); no other business modules required |
|
||||
| Internal Dependencies | None — independent of CRM / Loyalty / Restaurant |
|
||||
| External Dependencies | SMS providers (Payamak, mock); future email/push/social/voice adapters |
|
||||
@ -213,14 +213,14 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
|
||||
| Event Consumers | Any subscribed business module (via API/events only) |
|
||||
| Provider Dependencies | Payamak (SMS), Mock; stubs for smtp/fcm/whatsapp/telegram/rubika/voice |
|
||||
| AI Dependencies | None |
|
||||
| Documentation | [communication-phase-8.md](communication-phase-8.md), ADR-012, service README |
|
||||
| Current Phase | 8.10 complete |
|
||||
| Version | 0.8.10.0 |
|
||||
| Documentation | [communication-phase-8.md](communication-phase-8.md), [communication-roadmap.md](communication-roadmap.md), [phase-handover/phase-8.md](phase-handover/phase-8.md), [service-snapshots/communication.yaml](service-snapshots/communication.yaml), ADR-012, service README |
|
||||
| Current Phase | 8 complete (MVP) |
|
||||
| Version | 0.8.10.1 |
|
||||
| Version Compatibility | Standalone; Core JWT optional via AUTH_REQUIRED |
|
||||
| Migration Version | `0001_initial` |
|
||||
| Migration Version | `0001_initial`, `0002_validation_hardening` |
|
||||
| Tenant Aware | Yes (required) |
|
||||
| Permission Tree | `communication.*` |
|
||||
| Future Plans | Worker drain, additional channel adapters, optional Core OTP handoff |
|
||||
| Future Plans | See [communication-roadmap.md](communication-roadmap.md) — Email, Push, WhatsApp, Telegram, Rubika, Voice (not registered phases) |
|
||||
|
||||
---
|
||||
|
||||
@ -279,7 +279,12 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
|
||||
| `sports_center.digital_memberships` | Digital Memberships | Active | 9.2 |
|
||||
| `sports_center.waivers` | Waivers | Active | 9.2 |
|
||||
| `sports_center.member_documents` | Member Documents | Active | 9.2 |
|
||||
| `sports_center.coaches` | Coaches | Active (shell) | 9.0 |
|
||||
| `sports_center.coaches` | Coaches / Staff | Active | 9.3 |
|
||||
| `sports_center.staff_certificates` | Staff Certificates | Active | 9.3 |
|
||||
| `sports_center.staff_skills` | Staff Skills | Active | 9.3 |
|
||||
| `sports_center.staff_working_hours` | Staff Working Hours | Active | 9.3 |
|
||||
| `sports_center.staff_availability` | Staff Availability | Active | 9.3 |
|
||||
| `sports_center.staff_assignments` | Staff Assignments | Active | 9.3 |
|
||||
| `sports_center.roles` | Sports Roles | Active (shell) | 9.0 |
|
||||
| `sports_center.permissions` | Sports Permissions | Active (shell) | 9.0 |
|
||||
| `sports_center.facilities` | Facilities | Active (shell) | 9.0 |
|
||||
@ -294,13 +299,24 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
|
||||
| `sports_center.events` | Sports Events | Active (shell) | 9.0 |
|
||||
| `sports_center.settings` | Settings | Active (shell) | 9.0 |
|
||||
| `sports_center.audit` | Audit | Active | 9.0 |
|
||||
| `sports_center.attendance` | Attendance engine | Planned | 9.5 |
|
||||
| `sports_center.booking` | Booking | Planned | 9.4 |
|
||||
| `sports_center.equipment` | Equipment | Planned | 9.4 |
|
||||
| `sports_center.programs` | Programs | Planned | 9.6 |
|
||||
| `sports_center.workouts` | Workouts | Planned | 9.6 |
|
||||
| `sports_center.competitions` | Competitions | Planned | 9.7 |
|
||||
| `sports_center.competitions_events` | Competition events depth | Planned | 9.7 |
|
||||
| `sports_center.attendance` | Attendance engine | Active | 9.5 |
|
||||
| `sports_center.booking` | Booking | Active | 9.4 |
|
||||
| `sports_center.equipment` | Equipment | Active | 9.4 |
|
||||
| `sports_center.sessions` | Sports Sessions | Active | 9.4 |
|
||||
| `sports_center.programs` | Training Programs | Active | 9.6 |
|
||||
| `sports_center.workouts` | Workouts | Active | 9.6 |
|
||||
| `sports_center.exercises` | Exercises | Active | 9.6 |
|
||||
| `sports_center.training_goals` | Training Goals | Active | 9.6 |
|
||||
| `sports_center.measurements` | Measurements | Active | 9.6 |
|
||||
| `sports_center.competitions` | Competitions | Active | 9.7 |
|
||||
| `sports_center.competition_teams` | Competition Teams | Active | 9.7 |
|
||||
| `sports_center.competition_registrations` | Competition Registrations | Active | 9.7 |
|
||||
| `sports_center.competition_matches` | Competition Matches | Active | 9.7 |
|
||||
| `sports_center.competition_results` | Competition Results | Active | 9.7 |
|
||||
| `sports_center.competition_rankings` | Competition Rankings | Active | 9.7 |
|
||||
| `sports_center.competition_medals` | Competition Medals | Active | 9.7 |
|
||||
| `sports_center.competition_judges` | Competition Judges | Active | 9.7 |
|
||||
| `sports_center.competition_certificates` | Competition Certificates | Active | 9.7 |
|
||||
| `sports_center.nutrition` | Nutrition | Planned | Later slice |
|
||||
| `sports_center.mobile` | Mobile | Planned | Cross-cutting |
|
||||
| `sports_center.reports` | Reports | Planned | 9.9 |
|
||||
@ -336,26 +352,26 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Name | Delivery & Fleet Platform (Torbat Driver) |
|
||||
| Description | Independent logistics platform: drivers, fleet, vehicles, dispatch, routing, tracking, POD, settlement intents, merchant connectors for all verticals |
|
||||
| Description | Independent logistics platform (Torbat Driver): foundation orgs/hubs/config/providers/routing-engine shells; drivers active; fleet/dispatch in later phases |
|
||||
| Owner | Platform |
|
||||
| Status | Planned (registration complete; implementation from Phase 10.0) |
|
||||
| Status | Active (Phase 10.1 drivers) |
|
||||
| Dependencies | Core entitlement; Accounting / CRM / Loyalty / Communication / verticals via API/Events when phases require them |
|
||||
| Internal Dependencies | Modules owned by this service only |
|
||||
| External Dependencies | Routing engines and fleet providers (adapters); notifications via Communication |
|
||||
| External Dependencies | Routing engines and fleet providers (adapters); notifications via Communication; Storage file refs for driver docs |
|
||||
| Database Ownership | Sole owner |
|
||||
| Database | `delivery_db` |
|
||||
| API Prefix | `/api/v1` (planned service on port 8007); `/health`, `/capabilities`, `/metrics` |
|
||||
| API Prefix | `/api/v1` (service on port 8007); `/health`, `/capabilities`, `/metrics` |
|
||||
| Permission Prefix | `delivery.*` |
|
||||
| Events | `delivery.driver.*`, `delivery.fleet.*`, `delivery.vehicle.*`, `delivery.dispatch.*`, `delivery.route.*`, `delivery.tracking.*`, `delivery.pod.*`, `delivery.settlement.*`, `delivery.shift.*`, `delivery.zone.*` (grow per phase) |
|
||||
| Events | `delivery.organization.*`, `delivery.hub.*`, `delivery.external_provider.*`, `delivery.routing_engine.*`, `delivery.configuration.*`, `delivery.setting.*`, `delivery.driver.*` (+ planned fleet/dispatch/...) |
|
||||
| Event Producers | Delivery |
|
||||
| Event Consumers | Accounting, Communication, CRM, Loyalty, Restaurant, Marketplace, Clinic, Sports Center, Analytics (future) |
|
||||
| Provider Dependencies | External routing/fleet provider interfaces (planned) |
|
||||
| Provider Dependencies | External routing/fleet provider interfaces (planned); Storage refs for documents |
|
||||
| AI Dependencies | Optional via contracts only (Phase 10.10) |
|
||||
| Documentation | [delivery-roadmap.md](delivery-roadmap.md), [phases/Delivery](phases/Delivery/README.md), [phase-handover/phase-dp-reg.md](phase-handover/phase-dp-reg.md), [ADR-015](architecture/adr/ADR-015.md) |
|
||||
| Current Phase | DP-Reg complete; next `delivery-10.0` |
|
||||
| Version | 0.0.0 |
|
||||
| Documentation | [delivery-phase-10-1.md](delivery-phase-10-1.md), [phase-handover/phase-10-1.md](phase-handover/phase-10-1.md), [delivery-phase-10-0.md](delivery-phase-10-0.md), [delivery-roadmap.md](delivery-roadmap.md), [phases/Delivery](phases/Delivery/README.md), [ADR-015](architecture/adr/ADR-015.md) |
|
||||
| Current Phase | 10.1 complete (drivers); next `delivery-10.2` |
|
||||
| Version | 0.10.1.0 |
|
||||
| Version Compatibility | Requires Core entitlement; integrations per later phases |
|
||||
| Migration Version | None yet (Phase 10.0) |
|
||||
| Migration Version | `0002_phase_101_drivers` |
|
||||
| Tenant Aware | Yes (required) |
|
||||
| Permission Tree | `delivery.*` |
|
||||
| Future Plans | Phases 10.0–10.10 per [phase-manifest.yaml](ai-framework/phase-manifest.yaml) |
|
||||
@ -365,7 +381,13 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
|
||||
|
||||
| Module Key | Name | Status | Phase |
|
||||
| --- | --- | --- | --- |
|
||||
| `delivery.drivers` | Driver Management | Planned | 10.1 |
|
||||
| `delivery.organizations` | Delivery Organizations | Active (shell) | 10.0 |
|
||||
| `delivery.hubs` | Delivery Hubs | Active (shell) | 10.0 |
|
||||
| `delivery.roles` | Delivery Roles | Active (shell) | 10.0 |
|
||||
| `delivery.permissions_catalog` | Delivery Permissions Catalog | Active (shell) | 10.0 |
|
||||
| `delivery.routing_engines` | Routing Engine Registrations | Active (shell) | 10.0 |
|
||||
| `delivery.settings` | Settings | Active (shell) | 10.0 |
|
||||
| `delivery.drivers` | Driver Management | Active | 10.1 |
|
||||
| `delivery.fleet` | Fleet Management | Planned | 10.2 |
|
||||
| `delivery.vehicle_types` | Vehicle Types | Planned | 10.2 |
|
||||
| `delivery.vehicles` | Vehicles | Planned | 10.2 |
|
||||
@ -388,9 +410,9 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
|
||||
| `delivery.dispatcher_panel_apis` | Dispatcher Panel APIs | Planned | 10.9 |
|
||||
| `delivery.fleet_analytics` | Fleet Analytics | Planned | 10.10 |
|
||||
| `delivery.ai_hooks` | AI Ready Hooks | Planned | 10.10 |
|
||||
| `delivery.external_providers` | External Providers Ready | Planned | 10.0+ |
|
||||
| `delivery.audit` | Audit | Planned | 10.0 |
|
||||
| `delivery.configuration` | Configuration | Planned | 10.0 |
|
||||
| `delivery.external_providers` | External Providers Ready | Active (shell) | 10.0 |
|
||||
| `delivery.audit` | Audit | Active | 10.0 |
|
||||
| `delivery.configuration` | Configuration | Active (shell) | 10.0 |
|
||||
|
||||
| Module | Responsibilities | Non-responsibilities |
|
||||
| --- | --- | --- |
|
||||
@ -405,34 +427,289 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
|
||||
|
||||
---
|
||||
|
||||
## experience
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Name | Enterprise Experience Platform (Torbat Pages) |
|
||||
| Description | Independent experience platform: sites, pages-as-resources, versioned components, themes, layouts, templates, localization, forms/surveys/appointments, SEO/PWA, bundles, widgets for all verticals |
|
||||
| Owner | Platform |
|
||||
| Status | Active (Phase 11.0 foundation complete) |
|
||||
| Dependencies | Core entitlement; Storage / CRM / Loyalty / Communication / Hospitality / Marketplace / Sports / Delivery / AI / Automation via API/Events when phases require them |
|
||||
| Internal Dependencies | Modules owned by this service only |
|
||||
| External Dependencies | File Storage for media refs; notifications via Communication |
|
||||
| Database Ownership | Sole owner |
|
||||
| Database | `experience_db` |
|
||||
| API Prefix | `/api/v1` (port 8008); `/health`, `/capabilities`, `/metrics` |
|
||||
| Permission Prefix | `experience.*` |
|
||||
| Events | `experience.workspace.*`, `experience.locale_profile.*`, `experience.external_provider.*`, `experience.render_engine.*`, `experience.configuration.*`, `experience.setting.*`, `experience.site.*`, `experience.page.*`, `experience.site_domain.*`, `experience.component.*`, `experience.component_version.*`, `experience.page_component.*`, `experience.theme.*`, `experience.layout.*`, `experience.site_theme.*`, `experience.page_layout.*` (+ planned template/…) |
|
||||
| Event Producers | Experience |
|
||||
| Event Consumers | Hospitality, Marketplace, Sports Center, CRM, Loyalty, Communication, Accounting, Clinic, Hotel, Tourism, Analytics (future) |
|
||||
| Provider Dependencies | Optional theme/component marketplace adapters (planned) |
|
||||
| AI Dependencies | Optional via contracts only (Phase 11.10) |
|
||||
| Documentation | [experience-roadmap.md](experience-roadmap.md), [phases/Experience](phases/Experience/README.md), [experience-phase-11-3.md](experience-phase-11-3.md), [phase-handover/phase-11-3.md](phase-handover/phase-11-3.md), [ADR-016](architecture/adr/ADR-016.md) |
|
||||
| Current Phase | 11.3 complete; next `experience-11.4` |
|
||||
| Version | 0.11.3.0 |
|
||||
| Version Compatibility | Requires Core entitlement; integrations per later phases |
|
||||
| Migration Version | `0002_phase_111_sites_pages` |
|
||||
| Tenant Aware | Yes (required) |
|
||||
| Permission Tree | `experience.*` |
|
||||
| Future Plans | Phases 11.0–11.10 per [phase-manifest.yaml](ai-framework/phase-manifest.yaml) |
|
||||
| Commercial Product | Torbat Pages |
|
||||
|
||||
### Modules (owned by experience)
|
||||
|
||||
| Module Key | Name | Status | Phase |
|
||||
| --- | --- | --- | --- |
|
||||
| `experience.workspaces` | Workspaces | Complete | 11.0 |
|
||||
| `experience.locale_profiles` | Locale Profiles | Complete | 11.0 |
|
||||
| `experience.external_providers` | External Providers | Complete | 11.0 |
|
||||
| `experience.render_engines` | Render Engines | Complete | 11.0 |
|
||||
| `experience.audit` | Audit | Complete | 11.0 |
|
||||
| `experience.configuration` | Configuration | Complete | 11.0 |
|
||||
| `experience.sites` | Sites | Complete | 11.1 |
|
||||
| `experience.pages` | Page Resources | Complete | 11.1 |
|
||||
| `experience.page_types` | Page Types | Complete | 11.1 |
|
||||
| `experience.domains` | Site Domains | Complete | 11.1 |
|
||||
| `experience.components` | Components | Complete | 11.2 |
|
||||
| `experience.component_versions` | Component Versions | Complete | 11.2 |
|
||||
| `experience.page_component_placements` | Page Component Placements | Complete | 11.2 |
|
||||
| `experience.themes` | Themes | Complete | 11.3 |
|
||||
| `experience.layouts` | Layouts | Complete | 11.3 |
|
||||
| `experience.site_theme_assignments` | Site Theme Assignments | Complete | 11.3 |
|
||||
| `experience.page_layout_assignments` | Page Layout Assignments | Complete | 11.3 |
|
||||
| `experience.templates` | Templates | Planned | 11.4 |
|
||||
| `experience.locales` | Localization | Planned | 11.5 |
|
||||
| `experience.media_refs` | Media References | Planned | 11.5 |
|
||||
| `experience.forms` | Forms | Planned | 11.6 |
|
||||
| `experience.surveys` | Surveys | Planned | 11.6 |
|
||||
| `experience.appointments` | Appointment Pages | Planned | 11.6 |
|
||||
| `experience.publishing` | Publishing | Planned | 11.7 |
|
||||
| `experience.seo` | SEO | Planned | 11.7 |
|
||||
| `experience.pwa` | PWA Readiness | Planned | 11.7 |
|
||||
| `experience.bundles` | Capability Bundles | Planned | 11.8 |
|
||||
| `experience.feature_toggles` | Feature Toggles | Planned | 11.8 |
|
||||
| `experience.widgets` | Widgets | Planned | 11.9 |
|
||||
| `experience.consumer_connectors` | Consumer Connectors | Planned | 11.9 |
|
||||
| `experience.analytics` | Analytics | Planned | 11.10 |
|
||||
| `experience.ai_hooks` | AI Content Hooks | Planned | 11.10 |
|
||||
|
||||
| Module | Responsibilities | Non-responsibilities |
|
||||
| --- | --- | --- |
|
||||
| Sites / Pages | Site and page resources + lifecycle | Vertical menu/product source of truth |
|
||||
| Components | Versioned building blocks | Binary asset storage |
|
||||
| Themes / Layouts | Replaceable themes; dynamic layouts | Core white-label tenant brand ownership |
|
||||
| Templates | Starter packs and instantiation | Frontend builder UI |
|
||||
| Forms / Surveys | Form/survey definitions + submission shells | Communication provider ownership |
|
||||
| Publishing / SEO / PWA | Publish workflow + SEO/PWA shells | Nginx/DNS/SSL edge ownership |
|
||||
| Bundles / Toggles | Commercial packs + feature flags | Core plan/subscription engine |
|
||||
| Widgets / Connectors | Embed + vertical integration contracts | Owning CRM/Hospitality aggregates |
|
||||
| Analytics / AI | Shells + optional AI contracts | Mandatory AI for publish |
|
||||
|
||||
---
|
||||
|
||||
## restaurant
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Name | Restaurant / Cafe |
|
||||
| Description | Digital menu, tables, orders, kitchen; loyalty via Loyalty service |
|
||||
| Owner | TBD |
|
||||
| Status | Scaffolded |
|
||||
| Dependencies | Core entitlement, white-label host; Loyalty (API/Events) |
|
||||
| Internal Dependencies | Optional accounting postings via events |
|
||||
| External Dependencies | Payment provider (future) |
|
||||
| Name | Restaurant / Cafe (historical) |
|
||||
| Description | Superseded by Hospitality Platform — see hospitality |
|
||||
| Owner | Platform |
|
||||
| Status | Superseded |
|
||||
| Documentation | [phases/Restaurant](phases/Restaurant/README.md), [hospitality](#hospitality) |
|
||||
| Current Phase | hospitality-12.0 |
|
||||
| Version | 0.12.0.0 |
|
||||
| Notes | Pointer only; runtime is `hospitality` |
|
||||
|
||||
---
|
||||
|
||||
## hospitality
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Name | Hospitality Platform |
|
||||
| Description | Independent F&B platform (Torbat Food): venues, digital menu catalog, tables, QR menu & QR ordering shells, table service & reservations, bundle licensing, feature toggles; future POS/kitchen/connectors |
|
||||
| Owner | Platform |
|
||||
| Status | Active (Phase 12.8 Analytics) |
|
||||
| Dependencies | Core entitlement; Accounting / CRM / Loyalty / Communication / Delivery / Experience via API/Events when phases require them |
|
||||
| Internal Dependencies | None — independent of CRM / Loyalty / Delivery / Experience DBs |
|
||||
| External Dependencies | Payment provider (future); Storage refs for media |
|
||||
| Database Ownership | Sole owner |
|
||||
| Database | `restaurant_db` |
|
||||
| API Prefix | `/api/v1` |
|
||||
| Permission Prefix | `restaurant.*` |
|
||||
| Events | `order.*` (planned) |
|
||||
| Event Producers | Restaurant |
|
||||
| Event Consumers | Accounting, notification |
|
||||
| Provider Dependencies | Payment (planned) |
|
||||
| Database | `hospitality_db` |
|
||||
| API Prefix | `/api/v1` (service on port 8009); `/health`, `/capabilities` |
|
||||
| Permission Prefix | `hospitality.*` |
|
||||
| Events | `hospitality.venue.*`, `hospitality.menu.*`, `hospitality.allergen.*`, `hospitality.modifier_*`, `hospitality.availability_window.*`, `hospitality.menu_media_ref.*`, `hospitality.menu_localization.*`, `hospitality.qr_code.*`, `hospitality.qr_menu_session.*`, `hospitality.qr_ordering_session.*`, `hospitality.cart.*`, `hospitality.cart_line.*`, `hospitality.reservation.*`, `hospitality.table_assignment.*`, `hospitality.service_request.*`, `hospitality.waitlist_entry.*`, `hospitality.pos_register.*`, `hospitality.pos_shift.*`, `hospitality.pos_ticket.*`, `hospitality.pos_ticket_line.*`, `hospitality.pos_payment.*`, `hospitality.pos_discount.*`, `hospitality.pos_tax_line.*`, `hospitality.pos_floor_plan.*`, `hospitality.pos_station.*`, `hospitality.kitchen_station.*`, `hospitality.kitchen_ticket.*`, `hospitality.kitchen_ticket_item.*`, `hospitality.connector_registration.*`, `hospitality.connector_dispatch.*`, `hospitality.analytics_report_definition.*`, `hospitality.analytics_snapshot.*`, `hospitality.tenant_bundle.*`, `hospitality.feature_toggle.*`, … |
|
||||
| Event Producers | Hospitality |
|
||||
| Event Consumers | Accounting, CRM, Loyalty, Communication, Delivery, Experience (subscribed) |
|
||||
| Provider Dependencies | Contracts only through 12.8 (mock implementations for local dispatch simulation) |
|
||||
| AI Dependencies | Optional |
|
||||
| Documentation | [phases/Restaurant](phases/Restaurant/README.md) |
|
||||
| Current Phase | Candidate first business module after white-label |
|
||||
| Version | 0.0.0 |
|
||||
| Version Compatibility | Core + public tenant site |
|
||||
| Migration Version | None |
|
||||
| Tenant Aware | Yes |
|
||||
| Permission Tree | `restaurant.*` |
|
||||
| Future Plans | Guest ordering on tenant domain |
|
||||
| Documentation | [hospitality-phase-12-8.md](hospitality-phase-12-8.md), [hospitality-phase-12-7.md](hospitality-phase-12-7.md), [hospitality-phase-12-6.md](hospitality-phase-12-6.md), [hospitality-phase-12-5.md](hospitality-phase-12-5.md), [hospitality-phase-12-4.md](hospitality-phase-12-4.md), [hospitality-phase-12-3.md](hospitality-phase-12-3.md), [hospitality-phase-12-2.md](hospitality-phase-12-2.md), [hospitality-phase-12-1.md](hospitality-phase-12-1.md), [hospitality-phase-12-0.md](hospitality-phase-12-0.md), [hospitality-roadmap.md](hospitality-roadmap.md), ADR-017, service README |
|
||||
| Current Phase | 12.8 complete |
|
||||
| Version | 0.12.8.0 |
|
||||
| Version Compatibility | Standalone; Core JWT optional via AUTH_REQUIRED |
|
||||
| Migration Version | `0009_phase_128_analytics` |
|
||||
| Tenant Aware | Yes (required) |
|
||||
| Permission Tree | `hospitality.*` |
|
||||
| Commercial Product | Torbat Food |
|
||||
| Future Plans | AI Assistant (12.9), Enterprise Validation (12.10) |
|
||||
|
||||
---
|
||||
|
||||
## healthcare
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Name | Healthcare Platform (Torbat Health) |
|
||||
| Description | Independent healthcare platform: clinics, doctors, patients, appointment engine, doctor panel, clinic management, patient portal, medical records, pharmacy network, Delivery integration |
|
||||
| Owner | Platform |
|
||||
| Status | **Planned** (HC-Reg documentation complete; implementation not started) |
|
||||
| Dependencies | Core entitlement; Identity / Communication / CRM / Loyalty / Accounting / Delivery / File Storage via API/Events when phases require them |
|
||||
| Internal Dependencies | Modules owned by this service only |
|
||||
| External Dependencies | Delivery platform (Phase 13.7); Storage refs for documents/imaging |
|
||||
| Database Ownership | Sole owner (planned) |
|
||||
| Database | `healthcare_db` |
|
||||
| API Prefix | `/api/v1` (service on port **8010** reserved); `/health`, `/capabilities`, `/metrics` |
|
||||
| Permission Prefix | `healthcare.*` |
|
||||
| Events | `healthcare.clinic.*`, `healthcare.doctor.*`, `healthcare.patient.*`, `healthcare.appointment.*`, `healthcare.visit_session.*`, `healthcare.medical_record.*`, `healthcare.prescription_order.*`, `healthcare.delivery_job_intent.*` (+ planned) |
|
||||
| Event Producers | Healthcare (planned) |
|
||||
| Event Consumers | Communication, Delivery, CRM, Loyalty, Accounting, Experience (future) |
|
||||
| Provider Dependencies | Contracts only — Communication, Delivery, Storage, CRM, Accounting, AI |
|
||||
| AI Dependencies | Optional via contracts only (future) |
|
||||
| Documentation | [healthcare-roadmap.md](healthcare-roadmap.md), [phases/Healthcare](phases/Healthcare/README.md) |
|
||||
| Current Phase | HC-Reg complete; next `healthcare-13-0` |
|
||||
| Version | 0.0.0 (planned foundation `0.13.0.0`) |
|
||||
| Version Compatibility | Requires Core entitlement |
|
||||
| Migration Version | None (planned `0001_initial` at 13.0) |
|
||||
| Tenant Aware | Yes (required) |
|
||||
| Permission Tree | `healthcare.*` |
|
||||
| Future Plans | Phases 13.0–13.7 per [healthcare-roadmap.md](healthcare-roadmap.md) |
|
||||
| Commercial Product | Torbat Health |
|
||||
|
||||
### Modules (owned by healthcare — planned)
|
||||
|
||||
| Module Key | Name | Status | Phase |
|
||||
| --- | --- | --- | --- |
|
||||
| `healthcare.clinics` | Clinics | Planned | 13.0 |
|
||||
| `healthcare.branches` | Branches | Planned | 13.0 |
|
||||
| `healthcare.departments` | Departments | Planned | 13.0 |
|
||||
| `healthcare.doctors` | Doctors | Planned | 13.0 |
|
||||
| `healthcare.patients` | Patients | Planned | 13.0 |
|
||||
| `healthcare.roles` | Healthcare Roles | Planned | 13.0 |
|
||||
| `healthcare.permissions_catalog` | Permissions Catalog | Planned | 13.0 |
|
||||
| `healthcare.configuration` | Configuration | Planned | 13.0 |
|
||||
| `healthcare.settings` | Settings | Planned | 13.0 |
|
||||
| `healthcare.external_providers` | External Providers | Planned | 13.0 |
|
||||
| `healthcare.audit` | Audit | Planned | 13.0 |
|
||||
| `healthcare.schedules` | Schedules | Planned | 13.1 |
|
||||
| `healthcare.availability` | Availability | Planned | 13.1 |
|
||||
| `healthcare.appointment_types` | Appointment Types | Planned | 13.1 |
|
||||
| `healthcare.appointments` | Appointment Engine | Planned | 13.1 |
|
||||
| `healthcare.waiting_list` | Waiting List | Planned | 13.1 |
|
||||
| `healthcare.doctor_panel` | Doctor Panel | Planned | 13.2 |
|
||||
| `healthcare.visit_sessions` | Visit Sessions | Planned | 13.2 |
|
||||
| `healthcare.visit_notes` | Visit Notes | Planned | 13.2 |
|
||||
| `healthcare.clinic_services` | Clinic Services Catalog | Planned | 13.3 |
|
||||
| `healthcare.staff_assignments` | Staff Assignments | Planned | 13.3 |
|
||||
| `healthcare.clinic_policies` | Clinic Policies | Planned | 13.3 |
|
||||
| `healthcare.patient_portal` | Patient Portal | Planned | 13.4 |
|
||||
| `healthcare.patient_documents` | Patient Documents | Planned | 13.4 |
|
||||
| `healthcare.medical_records` | Medical Records | Planned | 13.5 |
|
||||
| `healthcare.encounters` | Encounters | Planned | 13.5 |
|
||||
| `healthcare.clinical_lists` | Conditions / Allergies / Medications | Planned | 13.5 |
|
||||
| `healthcare.pharmacies` | Pharmacy Network | Planned | 13.6 |
|
||||
| `healthcare.prescriptions` | Prescriptions | Planned | 13.6 |
|
||||
| `healthcare.prescription_orders` | Prescription Orders | Planned | 13.6 |
|
||||
| `healthcare.delivery_integration` | Delivery Integration | Planned | 13.7 |
|
||||
| `healthcare.delivery_job_intents` | Delivery Job Intents | Planned | 13.7 |
|
||||
|
||||
| Module | Responsibilities | Non-responsibilities |
|
||||
| --- | --- | --- |
|
||||
| Clinics / Branches | Healthcare org structure | Core tenant/workspace admin |
|
||||
| Doctors / Patients | Clinical actor profiles (refs to Identity) | Identity user admin |
|
||||
| Appointments | Scheduling and lifecycle | Communication provider ownership |
|
||||
| Doctor Panel | Visit queue and session shells | Full EHR legal archive |
|
||||
| Clinic Management | Services catalog, staff, policies | Accounting postings |
|
||||
| Patient Portal | Patient self-service APIs | Frontend UI ownership |
|
||||
| Medical Records | Encounters and clinical lists | National HIE / FHIR mandate |
|
||||
| Pharmacy Network | Prescription routing and fulfillment status | Drug inventory accounting |
|
||||
| Delivery Integration | Job intents to Delivery platform | Drivers, fleet, dispatch, routing |
|
||||
|
||||
---
|
||||
|
||||
## beauty_business
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Name | Beauty Business Platform (Torbat Beauty) |
|
||||
| Description | Independent beauty & wellness platform: salons, barbershops, beauty clinics, laser centers, skin-care centers, hair-treatment centers, spa & wellness; booking, salon management, service catalog, customers, packages, staff commission, marketing/Loyalty integration |
|
||||
| Owner | Platform |
|
||||
| Status | **Planned** (BB-Reg documentation complete; implementation not started) |
|
||||
| Dependencies | Core entitlement; Identity / Communication / CRM / Loyalty / Accounting / Delivery / Experience via API/Events when phases require them |
|
||||
| Internal Dependencies | Modules owned by this service only |
|
||||
| External Dependencies | Storage refs for media/documents; Loyalty and Communication via client contracts |
|
||||
| Database Ownership | Sole owner (planned) |
|
||||
| Database | `beauty_business_db` |
|
||||
| API Prefix | `/api/v1` (service on port **8011** reserved); `/health`, `/capabilities`, `/metrics` |
|
||||
| Permission Prefix | `beauty_business.*` |
|
||||
| Events | `beauty_business.organization.*`, `beauty_business.branch.*`, `beauty_business.appointment.*`, `beauty_business.service.*`, `beauty_business.customer.*`, `beauty_business.package.*`, `beauty_business.membership.*`, `beauty_business.staff.*`, `beauty_business.commission.*`, `beauty_business.marketing.*` (+ planned) |
|
||||
| Event Producers | Beauty Business (planned) |
|
||||
| Event Consumers | Communication, Loyalty, CRM, Accounting, Experience, Delivery (future) |
|
||||
| Provider Dependencies | Contracts only — Communication, Loyalty, CRM, Accounting, Experience, Delivery, Storage, AI |
|
||||
| AI Dependencies | Optional via contracts only (future) |
|
||||
| Documentation | [beauty-business-roadmap.md](beauty-business-roadmap.md), [phases/BeautyBusiness](phases/BeautyBusiness/README.md) |
|
||||
| Current Phase | BB-Reg complete; next `beauty-business-14-0` |
|
||||
| Version | 0.0.0 (planned foundation `0.14.0.0`) |
|
||||
| Version Compatibility | Requires Core entitlement |
|
||||
| Migration Version | None (planned `0001_initial` at 14.0) |
|
||||
| Tenant Aware | Yes (required) |
|
||||
| Permission Tree | `beauty_business.*` |
|
||||
| Future Plans | Phases 14.0–14.7 per [beauty-business-roadmap.md](beauty-business-roadmap.md); post-14.7 retail POS, analytics, mobile |
|
||||
| Commercial Product | Torbat Beauty |
|
||||
|
||||
**Independence from Healthcare:** Beauty Business and Healthcare are sibling modules. Separate documentation, phases, frontend (`/beauty` vs `/healthcare`), dashboards, permissions, navigation, and branding. Shared Core platforms only via API/events.
|
||||
|
||||
### Modules (owned by beauty_business — planned)
|
||||
|
||||
| Module Key | Name | Status | Phase |
|
||||
| --- | --- | --- | --- |
|
||||
| `beauty_business.organizations` | Beauty Organizations | Planned | 14.0 |
|
||||
| `beauty_business.branches` | Branches | Planned | 14.0 |
|
||||
| `beauty_business.roles` | Beauty Roles | Planned | 14.0 |
|
||||
| `beauty_business.permissions_catalog` | Permissions Catalog | Planned | 14.0 |
|
||||
| `beauty_business.configuration` | Configuration | Planned | 14.0 |
|
||||
| `beauty_business.settings` | Settings | Planned | 14.0 |
|
||||
| `beauty_business.external_providers` | External Providers | Planned | 14.0 |
|
||||
| `beauty_business.audit` | Audit | Planned | 14.0 |
|
||||
| `beauty_business.schedules` | Staff Schedules | Planned | 14.1 |
|
||||
| `beauty_business.appointments` | Booking Engine | Planned | 14.1 |
|
||||
| `beauty_business.waiting_list` | Waiting List | Planned | 14.1 |
|
||||
| `beauty_business.rooms` | Treatment Rooms | Planned | 14.2 |
|
||||
| `beauty_business.stations` | Stations | Planned | 14.2 |
|
||||
| `beauty_business.branch_policies` | Branch Policies | Planned | 14.2 |
|
||||
| `beauty_business.service_categories` | Service Categories | Planned | 14.3 |
|
||||
| `beauty_business.services` | Beauty Services | Planned | 14.3 |
|
||||
| `beauty_business.service_variants` | Service Variants | Planned | 14.3 |
|
||||
| `beauty_business.customers` | Customer Management | Planned | 14.4 |
|
||||
| `beauty_business.customer_preferences` | Customer Preferences | Planned | 14.4 |
|
||||
| `beauty_business.visit_history` | Visit History | Planned | 14.4 |
|
||||
| `beauty_business.packages` | Packages | Planned | 14.5 |
|
||||
| `beauty_business.memberships` | Memberships | Planned | 14.5 |
|
||||
| `beauty_business.staff` | Staff Profiles | Planned | 14.6 |
|
||||
| `beauty_business.commissions` | Commissions | Planned | 14.6 |
|
||||
| `beauty_business.marketing` | Marketing Hooks | Planned | 14.7 |
|
||||
| `beauty_business.loyalty_integration` | Loyalty Integration | Planned | 14.7 |
|
||||
| `beauty_business.communication_integration` | Communication Integration | Planned | 14.7 |
|
||||
|
||||
| Module | Responsibilities | Non-responsibilities |
|
||||
| --- | --- | --- |
|
||||
| Organizations / Branches | Beauty business org structure | Core tenant/workspace admin |
|
||||
| Booking | Appointments and waiting list | Communication reminder delivery |
|
||||
| Salon Management | Rooms, stations, branch policies | Healthcare clinical facilities |
|
||||
| Service Catalog | Beauty services and variants | CRM product catalog; clinical service codes |
|
||||
| Customers | Beauty customer context (CRM refs) | CRM Contact ownership; Healthcare patients |
|
||||
| Packages / Memberships | Prepaid sessions and plans | Loyalty ledger; Accounting subscriptions |
|
||||
| Staff / Commission | Stylists, rules, accrual shells | Identity admin; Accounting payroll engine |
|
||||
| Marketing / Loyalty | Integration refs and dispatch | Loyalty PointEngine; Communication providers |
|
||||
|
||||
---
|
||||
|
||||
@ -503,13 +780,13 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Name | Website Builder |
|
||||
| Description | Sites, pages, blocks, forms, menus, media |
|
||||
| Description | Historical scaffold for sites/pages/blocks — prefer Enterprise Experience Platform (`experience` / Torbat Pages) for new work (ADR-016) |
|
||||
| Owner | TBD |
|
||||
| Status | Scaffolded |
|
||||
| Status | Scaffolded (deferred; prefer `experience`) |
|
||||
| Dependencies | file_storage |
|
||||
| Internal Dependencies | — |
|
||||
| External Dependencies | S3 |
|
||||
| Database Ownership | Sole owner |
|
||||
| Database Ownership | Sole owner (historical scaffold) |
|
||||
| Database | `website_builder_db` |
|
||||
| API Prefix | `/api/v1` |
|
||||
| Permission Prefix | `website_builder.*` |
|
||||
@ -518,14 +795,14 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
|
||||
| Event Consumers | — |
|
||||
| Provider Dependencies | S3 |
|
||||
| AI Dependencies | Optional copy assist |
|
||||
| Documentation | service README |
|
||||
| Current Phase | Not started |
|
||||
| Documentation | service README; [ADR-016](architecture/adr/ADR-016.md) |
|
||||
| Current Phase | Not started — prefer Experience Phases 11.0+ |
|
||||
| Version | 0.0.0 |
|
||||
| Version Compatibility | Core |
|
||||
| Migration Version | None |
|
||||
| Tenant Aware | Yes |
|
||||
| Permission Tree | `website_builder.*` |
|
||||
| Future Plans | Tie into white-label public sites |
|
||||
| Future Plans | Do not expand; migrate capability intent to `experience` |
|
||||
|
||||
---
|
||||
|
||||
@ -782,9 +1059,17 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned**
|
||||
- [Provider Registry](provider-registry.md)
|
||||
- [Roadmap](roadmap.md)
|
||||
- [Architecture Overview](architecture/architecture.md)
|
||||
- [AI Development Framework](ai-framework/README.md)
|
||||
- [AI / Enterprise Development Framework](ai-framework/README.md)
|
||||
- [Phase Manifest](ai-framework/phase-manifest.yaml)
|
||||
- [Service Manifest](ai-framework/service-manifest.yaml)
|
||||
- [ADR-013](architecture/adr/ADR-013.md)
|
||||
- [ADR-018](architecture/adr/ADR-018.md)
|
||||
- [ADR-019](architecture/adr/ADR-019.md)
|
||||
- [Sports Center Roadmap](sports-center-roadmap.md)
|
||||
- [ADR-014](architecture/adr/ADR-014.md)
|
||||
- [Delivery Roadmap](delivery-roadmap.md)
|
||||
- [ADR-015](architecture/adr/ADR-015.md)
|
||||
- [Experience Roadmap](experience-roadmap.md)
|
||||
- [Healthcare Roadmap](healthcare-roadmap.md)
|
||||
- [Beauty Business Roadmap](beauty-business-roadmap.md)
|
||||
- [ADR-016](architecture/adr/ADR-016.md)
|
||||
|
||||
@ -1,40 +1,63 @@
|
||||
# Next Steps
|
||||
|
||||
> Immediate next milestone only. History → [progress.md](progress.md). Future map → [roadmap.md](roadmap.md).
|
||||
> Immediate next milestone only. History → [progress.md](progress.md).
|
||||
|
||||
## Current Milestone: Phase 7.2 — Loyalty Point Engine
|
||||
## Current Milestone: Phase 7.7 — Gift Card Platform (Loyalty)
|
||||
|
||||
Follow the permanent [AI Development Framework](ai-framework/README.md) ([cursor-guidelines.md](ai-framework/cursor-guidelines.md), [development-loop.md](ai-framework/development-loop.md), [quality-gates.md](ai-framework/quality-gates.md)).
|
||||
Follow the permanent [AI Development Framework](ai-framework/README.md).
|
||||
|
||||
### Why
|
||||
|
||||
Phase 7.1 Membership Engine is complete (lifecycle state machine, history, cascade policy, APIs/events).
|
||||
Phase 7.6 Wallet is complete. Gift cards are the next Loyalty engine.
|
||||
|
||||
Mandatory entry: [phase-handover/phase-7-1.md](phase-handover/phase-7-1.md).
|
||||
Mandatory entry: [phase-handover/phase-7-6.md](phase-handover/phase-7-6.md).
|
||||
|
||||
### Scope
|
||||
|
||||
1. Immutable point ledger as sole balance source (no mutable balance column)
|
||||
2. Earn / redeem / adjust / expire ledger entries with audit + outbox events
|
||||
3. PointAccount remains a shell linked to member/program — balances derived from ledger
|
||||
4. Do **not** implement rewards redemption engine (7.3) or campaign engine (7.4)
|
||||
1. Gift card issue / activate / redeem / void (or equivalent lifecycle)
|
||||
2. Keep ledger immutability (wallet and/or dedicated gift-card ledger)
|
||||
3. Permissions + events; do **not** build analytics/partner APIs yet
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- [ ] Ledger APIs + validators + tests green
|
||||
- [ ] Docs: `docs/loyalty-phase-7-2.md` + progress/registry + handover
|
||||
- [ ] Quality gates + phase handover per AI framework
|
||||
- [ ] Gift card APIs + validators + tests green
|
||||
- [ ] Docs + progress/registry + handover + service snapshot
|
||||
- [ ] Quality gates per AI framework
|
||||
|
||||
### After This Milestone
|
||||
|
||||
Phase 7.3 — Rewards & Redemption.
|
||||
Phase 7.8 — Analytics.
|
||||
|
||||
---
|
||||
|
||||
## Recently Completed: Healthcare Platform (Phases 13.0–13.7) ✅
|
||||
|
||||
Torbat Health is fully implemented. Next work is **post-13.7** (not registered phases):
|
||||
|
||||
- Live Delivery platform connector (replace mock when Delivery 10.9+)
|
||||
- National e-prescription / HIE connectors
|
||||
- Dedicated frontend patient mobile app
|
||||
|
||||
Handover: [phase-handover/phase-13-7.md](phase-handover/phase-13-7.md) · Snapshot: [service-snapshots/healthcare.yaml](service-snapshots/healthcare.yaml)
|
||||
|
||||
---
|
||||
|
||||
## Registered (Not Current Milestone)
|
||||
|
||||
| Registration | Status | Next implementation phase |
|
||||
| --- | --- | --- |
|
||||
| [Healthcare HC-Reg](healthcare-roadmap.md) | **Phases 13.0–13.7 complete** | Post-13.7 enhancements (unregistered) |
|
||||
| [Beauty Business BB-Reg](beauty-business-roadmap.md) | **Phases 14.0–14.7 complete** | Post-14.7 enhancements (unregistered) |
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [Progress](progress.md)
|
||||
- [Phase Handover 7.1](phase-handover/phase-7-1.md)
|
||||
- [Loyalty Phase 7.1](loyalty-phase-7-1.md)
|
||||
- [Loyalty Phase 7.0](loyalty-phase-7-0.md)
|
||||
- [Phases / Loyalty](phases/Loyalty/README.md)
|
||||
- [Phase Handover 7.6](phase-handover/phase-7-6.md)
|
||||
- [Phase Handover 14.7](phase-handover/phase-14-7.md)
|
||||
- [Phase Handover 13.7](phase-handover/phase-13-7.md)
|
||||
- [Healthcare Roadmap](healthcare-roadmap.md)
|
||||
- [Service Snapshot — Healthcare](service-snapshots/healthcare.yaml)
|
||||
- [Loyalty Phase 7.6](loyalty-phase-7-6.md)
|
||||
- [Beauty Business Roadmap](beauty-business-roadmap.md)
|
||||
- [Service Snapshot — Beauty Business](service-snapshots/beauty-business.yaml)
|
||||
- [AI Development Framework](ai-framework/README.md)
|
||||
- [ADR-011](architecture/adr/ADR-011.md)
|
||||
|
||||
101
docs/phase-handover/phase-7-2.md
Normal file
101
docs/phase-handover/phase-7-2.md
Normal 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)
|
||||
95
docs/phase-handover/phase-7-3.md
Normal file
95
docs/phase-handover/phase-7-3.md
Normal 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)
|
||||
66
docs/phase-handover/phase-7-4.md
Normal file
66
docs/phase-handover/phase-7-4.md
Normal 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)
|
||||
36
docs/phase-handover/phase-7-5.md
Normal file
36
docs/phase-handover/phase-7-5.md
Normal 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)
|
||||
101
docs/phase-handover/phase-7-6.md
Normal file
101
docs/phase-handover/phase-7-6.md
Normal 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)
|
||||
@ -10,11 +10,11 @@ Not part of CRM. Consumed by Restaurant, Marketplace, Ecommerce, Academy, Bookin
|
||||
| --- | --- | --- |
|
||||
| 7.0 | Foundation | Complete (validated) — [loyalty-phase-7-0.md](../../loyalty-phase-7-0.md) · [audit](../../loyalty-phase-7-0-audit.md) |
|
||||
| 7.1 | Membership Engine | Complete — [loyalty-phase-7-1.md](../../loyalty-phase-7-1.md) · [handover](../../phase-handover/phase-7-1.md) |
|
||||
| 7.2 | Points Engine (immutable ledger) | Planned |
|
||||
| 7.3 | Rewards Engine | Planned |
|
||||
| 7.4 | Campaign Engine | Planned |
|
||||
| 7.5 | Referral Engine | Planned |
|
||||
| 7.6 | Wallet | Planned |
|
||||
| 7.2 | Points Engine (immutable ledger) | Complete — [loyalty-phase-7-2.md](../../loyalty-phase-7-2.md) · [handover](../../phase-handover/phase-7-2.md) |
|
||||
| 7.3 | Rewards Engine | Complete — [loyalty-phase-7-3.md](../../loyalty-phase-7-3.md) · [handover](../../phase-handover/phase-7-3.md) |
|
||||
| 7.4 | Campaign Engine | Complete — [loyalty-phase-7-4.md](../../loyalty-phase-7-4.md) · [handover](../../phase-handover/phase-7-4.md) |
|
||||
| 7.5 | Referral Engine | Complete — [loyalty-phase-7-5.md](../../loyalty-phase-7-5.md) · [handover](../../phase-handover/phase-7-5.md) |
|
||||
| 7.6 | Wallet | Complete — [loyalty-phase-7-6.md](../../loyalty-phase-7-6.md) · [handover](../../phase-handover/phase-7-6.md) |
|
||||
| 7.7 | Gift Card Platform | Planned |
|
||||
| 7.8 | Analytics | Planned |
|
||||
| 7.9 | Public / Partner / Webhook APIs | Planned |
|
||||
|
||||
610
docs/progress.md
610
docs/progress.md
@ -115,6 +115,19 @@ Remaining: Phase 5.12 AI UI blocked on AI provider; optional richer PDF export f
|
||||
|
||||
---
|
||||
|
||||
## Accounting Frontend Completion Loop (business workflows) ✅
|
||||
|
||||
- [x] Full sidebar/submenu audit (118 routes); generic BusinessDocs stubs replaced
|
||||
- [x] Thin BE APIs on existing models: asset transfer/dispose/revaluation; payroll contracts/components/payrolls; compliance workflows/approvals list/SoD/controls/violations
|
||||
- [x] Domain screens: `WorkflowScreens.tsx` + typed `SpecializedOpsPage` for budget/integration/documents/settings
|
||||
- [x] Money thousand-separators + party/item AJAX across operational forms
|
||||
- [x] Audit report: [audits/accounting-frontend-completion-report.md](audits/accounting-frontend-completion-report.md)
|
||||
- [x] Menu QA tracker: [frontend/accounting-menu-qa.md](frontend/accounting-menu-qa.md)
|
||||
|
||||
Remaining (honest): Phase 5.12 AI blocked; budget/DMS/integration still typed-ops interim until dedicated engines exist.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6.0 — CRM Service Foundation ✅
|
||||
|
||||
- [x] `backend/services/crm` service scaffold (API / services / repositories / models)
|
||||
@ -208,9 +221,52 @@ Remaining: Phase 5.12 AI UI blocked on AI provider; optional richer PDF export f
|
||||
- [x] Tests green (59) including `test_phase71.py`
|
||||
- [x] Docs: [loyalty-phase-7-1.md](loyalty-phase-7-1.md), [phase-handover/phase-7-1.md](phase-handover/phase-7-1.md), manifests, registry
|
||||
|
||||
## Phase 7.2 — Point Engine ✅
|
||||
|
||||
- [x] Immutable `PointLedgerEntry` append-only ledger; balance = SUM(signed amounts)
|
||||
- [x] No mutable balance on `PointAccount`
|
||||
- [x] Earn / redeem / adjust / expire APIs with idempotency + insufficient-balance checks
|
||||
- [x] Permissions `loyalty.points.{view,earn,redeem,adjust,expire,manage}`
|
||||
- [x] Events `loyalty.points.{earned,redeemed,adjusted,expired}`
|
||||
- [x] Alembic `0003_phase_72_points`; version `0.7.2.0`
|
||||
- [x] Tests green (61) including `test_phase72.py`
|
||||
- [x] Docs: [loyalty-phase-7-2.md](loyalty-phase-7-2.md), [phase-handover/phase-7-2.md](phase-handover/phase-7-2.md), [service-snapshots/loyalty.yaml](service-snapshots/loyalty.yaml), manifests, registry
|
||||
|
||||
## Phase 7.3 — Rewards Engine ✅
|
||||
|
||||
- [x] Reward inventory fields + `RewardRedemption` lifecycle (pending/fulfilled/cancelled)
|
||||
- [x] Redeem / fulfill / cancel; points via PointEngine only (`auto_commit` composition)
|
||||
- [x] Permissions `loyalty.rewards.{redeem,fulfill,cancel}`, `loyalty.redemptions.view`
|
||||
- [x] Events `loyalty.reward.{redeemed,fulfilled,redemption_cancelled}`
|
||||
- [x] Alembic `0004_phase_73_rewards`; version `0.7.3.0`
|
||||
- [x] Tests green (72) including `test_phase73.py`
|
||||
- [x] Docs: [loyalty-phase-7-3.md](loyalty-phase-7-3.md), [phase-handover/phase-7-3.md](phase-handover/phase-7-3.md), snapshot, manifests
|
||||
|
||||
## Phase 7.4 — Campaign Engine ✅
|
||||
|
||||
- [x] Campaign lifecycle + versioned rules + apply grants via PointEngine
|
||||
- [x] `CampaignApplication` tracking; Alembic `0005_phase_74_campaigns`; version `0.7.4.0`
|
||||
- [x] Tests green; docs [loyalty-phase-7-4.md](loyalty-phase-7-4.md), [phase-handover/phase-7-4.md](phase-handover/phase-7-4.md)
|
||||
|
||||
## Phase 7.5 — Referral Engine ✅
|
||||
|
||||
- [x] ReferralProgram / ReferralCode / ReferralAttribution lifecycle
|
||||
- [x] Dual-sided bonuses via PointEngine; Alembic `0006_phase_75_referral`; version `0.7.5.0`
|
||||
- [x] Tests green (103); docs [loyalty-phase-7-5.md](loyalty-phase-7-5.md), [phase-handover/phase-7-5.md](phase-handover/phase-7-5.md)
|
||||
|
||||
## Phase 7.6 — Wallet ✅
|
||||
|
||||
- [x] `WalletAccount` shell + immutable `WalletLedgerEntry` (no mutable balance)
|
||||
- [x] Credit / debit / adjust / transfer APIs with idempotency + insufficient-funds checks
|
||||
- [x] Permissions `loyalty.wallets.{view,create,credit,debit,adjust,transfer,delete,manage}`
|
||||
- [x] Events `loyalty.wallet.{opened,credited,debited,adjusted,transferred,closed}`
|
||||
- [x] Alembic `0007_phase_76_wallet`; version `0.7.6.0`
|
||||
- [x] Tests green (114) including `test_phase76.py`
|
||||
- [x] Docs: [loyalty-phase-7-6.md](loyalty-phase-7-6.md), [phase-handover/phase-7-6.md](phase-handover/phase-7-6.md), snapshot, manifests
|
||||
|
||||
---
|
||||
|
||||
## Phase 8.0–8.10 — Enterprise Communication Platform ✅
|
||||
## Phase 8.0–8.10 — Enterprise Communication Platform ✅ (Production Ready — SMS MVP)
|
||||
|
||||
- [x] Independent `communication-service` / `communication_db` (port 8005) — not owned by CRM/Loyalty/Restaurant
|
||||
- [x] Provider framework + registry (mock, payamak; stubs for email/push/whatsapp/telegram/rubika/voice)
|
||||
@ -224,9 +280,10 @@ Remaining: Phase 5.12 AI UI blocked on AI provider; optional richer PDF export f
|
||||
- [x] Incoming provider webhooks with signature verification
|
||||
- [x] Health `/health`, Capability `/capabilities`, providers status, monitoring stats
|
||||
- [x] Permissions `communication.*`; events `communication.*`; audit on every send
|
||||
- [x] Alembic `0001_initial`; version `0.8.10.0`
|
||||
- [x] Tests: architecture, health/security, providers/SMS/failover/queue, templates/contacts/OTP/webhooks, validators
|
||||
- [x] Docs: [communication-phase-8.md](communication-phase-8.md), ADR-012, module/provider registry, progress
|
||||
- [x] Alembic `0001_initial`, `0002_validation_hardening`; version `0.8.10.1`
|
||||
- [x] Tests: architecture, health/security, providers/SMS/failover/queue, templates/contacts/OTP/webhooks, validators (42 passing)
|
||||
- [x] **Production Ready (SMS MVP)** — feature-complete for current scope; future channels in [communication-roadmap.md](communication-roadmap.md) only
|
||||
- [x] Docs: [communication-phase-8.md](communication-phase-8.md), [phase-handover/phase-8.md](phase-handover/phase-8.md), [service-snapshots/communication.yaml](service-snapshots/communication.yaml), ADR-012, module/provider registry, progress
|
||||
|
||||
---
|
||||
|
||||
@ -245,6 +302,40 @@ Remaining: Phase 5.12 AI UI blocked on AI provider; optional richer PDF export f
|
||||
|
||||
---
|
||||
|
||||
## Phase AF-Enterprise — Enterprise Development Framework Upgrade ✅
|
||||
|
||||
> Documentation-only. Upgrades the AI Development Framework into an Enterprise Development Framework. No business feature code. No changes to completed business phases or implementations.
|
||||
|
||||
- [x] [Definition of Done](ai-framework/definition-of-done.md)
|
||||
- [x] [Mandatory Phase Artifacts](ai-framework/mandatory-phase-artifacts.md)
|
||||
- [x] [Enterprise Completeness](ai-framework/enterprise-completeness.md)
|
||||
- [x] [Boundary Rules](ai-framework/boundary-rules.md)
|
||||
- [x] Master prompt, development loop, quality gates, phase/handover/testing/documentation templates upgraded
|
||||
- [x] Layer templates (service, module, entity, repository, service-layer, API, event) upgraded
|
||||
- [x] Prompt rules + Cursor guidelines upgraded
|
||||
- [x] Project principles, testing strategy, docs index, glossary updated
|
||||
- [x] [ADR-018](architecture/adr/ADR-018.md) Accepted (extends ADR-013)
|
||||
- [x] Phase registered: `ai-framework-enterprise` in [phase-manifest.yaml](ai-framework/phase-manifest.yaml)
|
||||
- [x] Handover: [phase-handover/phase-af-enterprise.md](phase-handover/phase-af-enterprise.md)
|
||||
- [x] Backward-compatible path `docs/ai-framework/` retained; no business services modified
|
||||
|
||||
---
|
||||
|
||||
## Phase AF-Discovery — Enterprise Phase Discovery Mandate ✅
|
||||
|
||||
> Documentation-only. Adds mandatory Enterprise Phase Discovery before every phase. No business feature code. No changes to completed business phases or implementations.
|
||||
|
||||
- [x] [Enterprise Phase Discovery](ai-framework/enterprise-phase-discovery.md)
|
||||
- [x] Development loop stage 1 = Discovery; quality gates include Discovery
|
||||
- [x] Phase template, handover, DoD, completeness, mandatory artifacts, prompt/cursor rules updated
|
||||
- [x] Master prompt + project principles updated
|
||||
- [x] [ADR-019](architecture/adr/ADR-019.md) Accepted (extends ADR-018)
|
||||
- [x] Phase registered: `ai-framework-discovery` in [phase-manifest.yaml](ai-framework/phase-manifest.yaml)
|
||||
- [x] Handover: [phase-handover/phase-af-discovery.md](phase-handover/phase-af-discovery.md)
|
||||
- [x] No business services modified
|
||||
|
||||
---
|
||||
|
||||
## Phase SC-Reg — Sports Center Platform Registration ✅
|
||||
|
||||
> Documentation-only registration. No business code, models, APIs, migrations, or repositories.
|
||||
@ -305,6 +396,464 @@ Remaining: Phase 5.12 AI UI blocked on AI provider; optional richer PDF export f
|
||||
|
||||
---
|
||||
|
||||
## Phase 9.3 — Sports Center Coach & Staff Management ✅
|
||||
|
||||
- [x] Extended Coach profile (staff_kind, bio, hire_date, payroll refs)
|
||||
- [x] Staff certificates, skills, working hours, availability, assignments
|
||||
- [x] Alembic `0004_phase_93_coach_staff`; version `0.9.3.0`
|
||||
- [x] Docs + handover [phase-9-3.md](phase-handover/phase-9-3.md)
|
||||
|
||||
---
|
||||
|
||||
## Phase 9.4 — Sports Center Scheduling & Booking ✅
|
||||
|
||||
- [x] Sessions, bookings, waiting list, equipment, resource reservations
|
||||
- [x] Capacity checks and session/resource conflict detection
|
||||
- [x] Alembic `0005_phase_94_booking`; version `0.9.4.0`; `booking_engine: true`
|
||||
|
||||
---
|
||||
|
||||
## Phase 9.5 — Sports Center Attendance & Access Control ✅
|
||||
|
||||
- [x] Attendance records, access logs, check-in/check-out with membership validation
|
||||
- [x] Alembic `0006_phase_95_attendance`; version `0.9.5.0`; `attendance_engine: true`
|
||||
|
||||
---
|
||||
|
||||
## Phase 9.6 — Sports Center Training Management ✅
|
||||
|
||||
- [x] Training programs, exercises, workout plans/assignments, goals, measurements, progress, nutrition, assessments, performance, body composition
|
||||
- [x] Alembic `0007_phase_96_training`; version `0.9.6.0`; `training_management: true`
|
||||
- [x] Docs + handover [phase-9-6.md](phase-handover/phase-9-6.md)
|
||||
|
||||
---
|
||||
|
||||
## Phase 9.7 — Sports Center Competition & Event Management ✅
|
||||
|
||||
- [x] Competitions, teams, groups, registrations, matches, results, rankings, medals, judges, certificates
|
||||
- [x] Alembic `0008_phase_97_competitions`; version `0.9.7.0`; `competition_management: true`
|
||||
- [x] Docs + handover [phase-9-7.md](phase-handover/phase-9-7.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Phase DP-Reg — Delivery & Fleet Platform Registration ✅
|
||||
|
||||
> Documentation-only registration. No business code, models, APIs, migrations, or repositories.
|
||||
|
||||
- [x] Phase roadmap 10.0–10.10 registered in [phase-manifest.yaml](ai-framework/phase-manifest.yaml)
|
||||
- [x] Service registered in [service-manifest.yaml](ai-framework/service-manifest.yaml) (`delivery`, `delivery_db`, port 8007)
|
||||
- [x] Planned modules registered in [module-registry.md](module-registry.md#delivery)
|
||||
- [x] Delivery terminology added to [glossary.md](glossary.md#delivery--fleet-platform)
|
||||
- [x] Roadmap doc: [delivery-roadmap.md](delivery-roadmap.md)
|
||||
- [x] ADR-015 Accepted — Independent Delivery & Fleet Platform
|
||||
- [x] Module boundaries + phase area README updated
|
||||
- [x] Handover: [phase-handover/phase-dp-reg.md](phase-handover/phase-dp-reg.md)
|
||||
- [x] Manifest / cross-reference / documentation / module validation passed
|
||||
|
||||
---
|
||||
|
||||
## Phase XP-Reg — Experience Platform Registration ✅
|
||||
|
||||
> Documentation-only registration. No business code, models, APIs, migrations, or repositories.
|
||||
|
||||
- [x] Phase roadmap 11.0–11.10 registered in [phase-manifest.yaml](ai-framework/phase-manifest.yaml)
|
||||
- [x] Service registered in [service-manifest.yaml](ai-framework/service-manifest.yaml) (`experience`, `experience_db`, port 8008)
|
||||
- [x] Planned modules registered in [module-registry.md](module-registry.md#experience)
|
||||
- [x] Experience terminology added to [glossary.md](glossary.md#experience-platform)
|
||||
- [x] Roadmap doc: [experience-roadmap.md](experience-roadmap.md)
|
||||
- [x] Phase doc: [experience-phase-xp-reg.md](experience-phase-xp-reg.md)
|
||||
- [x] ADR-016 Accepted — Independent Enterprise Experience Platform (Torbat Pages)
|
||||
- [x] Module boundaries + database architecture + schema notes + phase area README updated
|
||||
- [x] Historical `website_builder` marked prefer-`experience` (ADR-016)
|
||||
- [x] Handover: [phase-handover/phase-xp-reg.md](phase-handover/phase-xp-reg.md)
|
||||
- [x] Manifest / cross-reference / documentation / module validation passed
|
||||
|
||||
---
|
||||
|
||||
## Phase 11.0 — Experience Platform Foundation ✅
|
||||
|
||||
- [x] Independent `backend/services/experience` (`experience_db`, port **8008**, version **0.11.0.0**, commercial product **Torbat Pages**)
|
||||
- [x] Foundation aggregates: Workspace, LocaleProfile, ExternalProviderConfig, RenderEngineRegistration, Configuration, Setting, Role/Permission shells, AuditLog
|
||||
- [x] `GET /health`, `/capabilities`, `/metrics`; permissions `experience.*`; publish-only events
|
||||
- [x] Provider contracts only (Storage, CRM, Hospitality, Marketplace, Sports, Delivery, Communication, Loyalty, Accounting, AI, Automation, …)
|
||||
- [x] Alembic `0001_initial`; compose + `.env.example` + `init-dbs.sql` wiring
|
||||
- [x] Tests: architecture, API, tenant isolation, permissions, migration, dependency, security, docs
|
||||
- [x] Docs: [experience-phase-11-0.md](experience-phase-11-0.md), [phase-handover/phase-11-0.md](phase-handover/phase-11-0.md)
|
||||
- [x] No sites/pages/page-builder/theme/template/CMS/forms engines
|
||||
|
||||
---
|
||||
|
||||
## Phase 11.1 — Experience Sites & Page Resources ✅
|
||||
|
||||
- [x] Sites under workspaces (multiple sites); draft/publish/archive lifecycle
|
||||
- [x] Pages-as-resources with page types (landing, menu, bio, portfolio, QR, blog, …)
|
||||
- [x] Site domain/subdomain binding refs (`experience_site_domains`)
|
||||
- [x] Permissions `experience.sites.*` / `experience.pages.*` / `experience.domains.*`
|
||||
- [x] Events `experience.site.*` / `experience.page.*` / `experience.site_domain.*`
|
||||
- [x] Alembic `0002_phase_111_sites_pages`; version `0.11.1.0`
|
||||
- [x] Tests: sites API, tenant isolation, publish/archive rules, architecture, migration, docs
|
||||
- [x] Docs: [experience-phase-11-1.md](experience-phase-11-1.md), [phase-handover/phase-11-1.md](phase-handover/phase-11-1.md)
|
||||
- [x] No page builder / theme / template engines
|
||||
|
||||
---
|
||||
|
||||
## Phase 11.2 — Experience Component Library & Versioning ✅
|
||||
|
||||
- [x] Versioned component catalog (`ExperienceComponent`) with kinds + lifecycle
|
||||
- [x] Immutable published component versions (`ExperienceComponentVersion`)
|
||||
- [x] Page composition shells (`PageComponentPlacement` slot pins)
|
||||
- [x] Policies, specifications, commands, queries; list filter/sort/search
|
||||
- [x] Permissions `experience.components.*`; events `experience.component*` / `experience.page_component.*`
|
||||
- [x] Alembic `0003_phase_112_components`; version `0.11.2.0`
|
||||
- [x] Tests: policies, API flow, immutability, tenant isolation, architecture, migration, docs
|
||||
- [x] Docs: [experience-phase-11-2.md](experience-phase-11-2.md), [phase-handover/phase-11-2.md](phase-handover/phase-11-2.md)
|
||||
- [x] No theme / layout engines
|
||||
|
||||
---
|
||||
|
||||
## Phase 11.3 — Experience Theme & Layout Engine ✅
|
||||
|
||||
- [x] Replaceable theme packs (`ExperienceTheme`) with tokens + directionality
|
||||
- [x] Dynamic layouts (`ExperienceLayout`) with regions/slots shells
|
||||
- [x] Site theme + page layout assignments (swap without rewriting pages)
|
||||
- [x] Policies, specifications, commands, queries; list filter/sort/search
|
||||
- [x] Permissions `experience.themes.*` / `experience.layouts.*`
|
||||
- [x] Events `experience.theme.*` / `experience.layout.*` / `experience.site_theme.*` / `experience.page_layout.*`
|
||||
- [x] Alembic `0004_phase_113_themes_layouts`; version `0.11.3.0`
|
||||
- [x] Tests: policies, API flow, tenant isolation, architecture, migration, docs
|
||||
- [x] Docs: [experience-phase-11-3.md](experience-phase-11-3.md), [phase-handover/phase-11-3.md](phase-handover/phase-11-3.md)
|
||||
- [x] No template catalog engine
|
||||
|
||||
---
|
||||
|
||||
## Phase 12.0 — Hospitality Platform Foundation ✅
|
||||
|
||||
- [x] Independent `backend/services/hospitality` (`hospitality_db`, port **8009**, version **0.12.0.0**, commercial product **Torbat Food**)
|
||||
- [x] Foundation aggregates: Venue, Branch, DiningArea, DiningTable, Menu, MenuCategory, MenuItem, BundleDefinition, TenantBundle, FeatureToggle, roles/permissions, configuration/settings/events/audit
|
||||
- [x] Feature-based architecture: bundle licensing + feature toggles + `/capabilities` discovery
|
||||
- [x] Venue formats catalog (cafe → catering) without hardcoded engines
|
||||
- [x] Publish-only events `hospitality.*`; permissions `hospitality.*`
|
||||
- [x] Provider contracts only (Accounting, CRM, Loyalty, Communication, Delivery, Experience/Website, AI, …)
|
||||
- [x] Alembic `0001_initial`; compose + `.env.example` wiring
|
||||
- [x] Tests: architecture, API, tenant isolation, permissions, bundles, migration, dependency, security, performance, docs
|
||||
- [x] Docs: [hospitality-phase-12-0.md](hospitality-phase-12-0.md), [phase-handover/phase-12-0.md](phase-handover/phase-12-0.md), [hospitality-roadmap.md](hospitality-roadmap.md), ADR-017
|
||||
- [x] Historical `restaurant-foundation` / restaurant scaffold superseded by hospitality
|
||||
- [x] No POS / kitchen / ordering / reservation engines
|
||||
|
||||
---
|
||||
|
||||
## Phase 12.1 — Digital Menu Catalog ✅
|
||||
|
||||
- [x] Catalog aggregates: Allergen, ModifierGroup, ModifierOption, MenuItemModifier, MenuItemAllergen, AvailabilityWindow, MenuMediaRef, MenuLocalization
|
||||
- [x] Additive MenuItem fields (prep time, calories, dietary flags, tags, primary_media_ref)
|
||||
- [x] Catalog APIs + validators + permissions + publish-only events
|
||||
- [x] Alembic `0002_phase_121_digital_menu_catalog`; version **0.12.1.0**; capabilities `digital_menu_catalog`
|
||||
- [x] Tests: catalog happy path, tenant isolation, validation, architecture/migration/docs updates
|
||||
- [x] Docs: [hospitality-phase-12-1.md](hospitality-phase-12-1.md), [phase-handover/phase-12-1.md](phase-handover/phase-12-1.md)
|
||||
- [x] No POS / kitchen / QR ordering / reservation engines
|
||||
|
||||
---
|
||||
|
||||
## Phase 12.2 — QR Menu & QR Ordering ✅
|
||||
|
||||
- [x] Aggregates: QrCode, QrMenuSession, QrOrderingSession, Cart, CartLine
|
||||
- [x] APIs + validators + permissions + publish-only events
|
||||
- [x] Alembic `0003_phase_122_qr_menu_ordering`; version **0.12.2.0**; capabilities `qr_menu`, `qr_ordering`
|
||||
- [x] Tests: QR happy path, tenant isolation, validation, architecture/migration/docs
|
||||
- [x] Docs: [hospitality-phase-12-2.md](hospitality-phase-12-2.md), [phase-handover/phase-12-2.md](phase-handover/phase-12-2.md)
|
||||
- [x] No POS / kitchen / payment / reservation engines
|
||||
|
||||
---
|
||||
|
||||
## Phase 12.3 — Table Service & Reservations ✅
|
||||
|
||||
- [x] Aggregates: Reservation, TableAssignment, ServiceRequest, WaitlistEntry
|
||||
- [x] APIs + validators + permissions + publish-only events (status transitions with terminal-state guards)
|
||||
- [x] Alembic `0004_phase_123_table_service_reservations`; version **0.12.3.0**; capabilities `table_service`, `reservation`
|
||||
- [x] Tests: reservation/table-assignment/service-request/waitlist happy paths, tenant isolation, validation, architecture/migration/docs
|
||||
- [x] Docs: [hospitality-phase-12-3.md](hospitality-phase-12-3.md), [phase-handover/phase-12-3.md](phase-handover/phase-12-3.md)
|
||||
- [x] No POS / kitchen / payment engines
|
||||
|
||||
---
|
||||
|
||||
## Phase 12.4 — POS Lite ✅
|
||||
|
||||
- [x] Aggregates: PosRegister, PosShift, PosTicket, PosTicketLine
|
||||
- [x] APIs + validators + permissions + publish-only events (shift open/close, ticket void)
|
||||
- [x] Alembic `0005_phase_124_pos_lite`; version **0.12.4.0**; capabilities `pos_lite`
|
||||
- [x] Tests: register/shift/ticket/line happy path, tenant isolation, quantity validation, architecture/migration/docs
|
||||
- [x] Docs: [hospitality-phase-12-4.md](hospitality-phase-12-4.md), [phase-handover/phase-12-4.md](phase-handover/phase-12-4.md)
|
||||
- [x] No POS Pro (payments/discounts/taxes) / kitchen engines
|
||||
|
||||
---
|
||||
|
||||
## Phase 12.5 — POS Pro ✅
|
||||
|
||||
- [x] Aggregates: PosPayment, PosDiscount, PosTaxLine, PosFloorPlan, PosStation
|
||||
- [x] APIs + validators + permissions + publish-only events
|
||||
- [x] Alembic `0006_phase_125_pos_pro`; version **0.12.5.0**; capabilities `pos_pro`
|
||||
- [x] Tests: payment/discount/tax/floor-plan/station happy path, tenant isolation, discount percent + payment amount validation, architecture/migration/docs
|
||||
- [x] Docs: [hospitality-phase-12-5.md](hospitality-phase-12-5.md), [phase-handover/phase-12-5.md](phase-handover/phase-12-5.md)
|
||||
- [x] No accounting journal posting / real payment gateway / kitchen dispatch
|
||||
|
||||
---
|
||||
|
||||
## Phase 12.6 — Kitchen ✅
|
||||
|
||||
- [x] Aggregates: KitchenStation, KitchenTicket, KitchenTicketItem
|
||||
- [x] APIs + validators + permissions + publish-only events (status transitions with terminal-state guards)
|
||||
- [x] Alembic `0007_phase_126_kitchen`; version **0.12.6.0**; capabilities `kitchen`, `kitchen_engine`
|
||||
- [x] Tests: station/ticket/item happy path, tenant isolation, quantity validation, architecture/migration/docs
|
||||
- [x] Docs: [hospitality-phase-12-6.md](hospitality-phase-12-6.md), [phase-handover/phase-12-6.md](phase-handover/phase-12-6.md)
|
||||
- [x] No connector integrations yet; kitchen tickets reference POS/QR sources by UUID only
|
||||
|
||||
---
|
||||
|
||||
## Phase 12.7 — Connectors ✅
|
||||
|
||||
- [x] Aggregates: ConnectorRegistration, ConnectorDispatch
|
||||
- [x] `app/providers/mocks.py`: no-op mocks for Accounting/CRM/Loyalty/Communication/Delivery/WebsiteBuilder Protocols (no httpx, no cross-service imports)
|
||||
- [x] APIs + validators + permissions + publish-only events (dispatch → `sent` if registration active, else `failed`)
|
||||
- [x] Alembic `0008_phase_127_connectors`; version **0.12.7.0**; capabilities `delivery_integration`, `accounting_integration`, `crm_integration`, `loyalty_integration`, `communication_integration`, `website_integration`
|
||||
- [x] Tests: registration/dispatch happy path, tenant isolation, code uniqueness, architecture/migration/docs
|
||||
- [x] Docs: [hospitality-phase-12-7.md](hospitality-phase-12-7.md), [phase-handover/phase-12-7.md](phase-handover/phase-12-7.md)
|
||||
- [x] No real connector SDK calls; all dispatch responses are locally mocked
|
||||
|
||||
---
|
||||
|
||||
## Phase 12.8 — Analytics ✅
|
||||
|
||||
- [x] Aggregates: AnalyticsReportDefinition, AnalyticsSnapshot
|
||||
- [x] APIs + validators + permissions + publish-only events (`POST /analytics-snapshots/refresh` computes metrics via local `COUNT(*)` queries)
|
||||
- [x] Alembic `0009_phase_128_analytics`; version **0.12.8.0**; capability `analytics`
|
||||
- [x] Tests: report/snapshot happy path, tenant isolation, code uniqueness, unknown metric_key rejection, architecture/migration/docs
|
||||
- [x] Docs: [hospitality-phase-12-8.md](hospitality-phase-12-8.md), [phase-handover/phase-12-8.md](phase-handover/phase-12-8.md)
|
||||
- [x] No external analytics engine or cross-service reads; local table counts only
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Phase 10.0 — Delivery Platform Foundation ✅
|
||||
|
||||
- [x] `backend/services/delivery` service scaffold (API / services / repositories / models)
|
||||
- [x] Foundation aggregates: DeliveryOrganization, DeliveryHub, DeliveryRole, DeliveryPermission, ExternalProviderConfig, RoutingEngineRegistration, DeliveryConfiguration, DeliverySetting, DeliveryAuditLog
|
||||
- [x] Platform provider contracts (Accounting, CRM, Loyalty, Communication, Notification, Storage, AI, Identity, RoutingEngine, Fleet)
|
||||
- [x] Publish-only events (`delivery.organization|hub|external_provider|routing_engine|configuration|setting.*`)
|
||||
- [x] Health `/health`, Capabilities `/capabilities`, Metrics `/metrics`
|
||||
- [x] APIs under `/api/v1` for organizations, hubs, external-providers, routing-engines, configurations, settings, audit
|
||||
- [x] Permissions `delivery.*` trees; tenant isolation via `X-Tenant-ID`
|
||||
- [x] Alembic `0001_initial` + `delivery_db` wiring (compose port 8007); version `0.10.0.0`
|
||||
- [x] Architecture / API / tenant / permissions / migration / dependency / security / docs tests
|
||||
- [x] Docs: [delivery-phase-10-0.md](delivery-phase-10-0.md), [phase-handover/phase-10-0.md](phase-handover/phase-10-0.md)
|
||||
- [x] No driver/fleet/dispatch/routing/tracking engines (deferred)
|
||||
|
||||
---
|
||||
|
||||
## Phase 10.1 — Driver Management ✅
|
||||
|
||||
- [x] Aggregates: Driver, DriverCredential, DriverDocument, DriverLifecycleEvent, OutboxEvent
|
||||
- [x] Lifecycle: activate / suspend / resume / deactivate / block / unblock / archive + append-only history
|
||||
- [x] Soft delete + optimistic locking; list pagination / filter / sort / search
|
||||
- [x] Commands, queries, policies, specifications, validators
|
||||
- [x] APIs `/api/v1/drivers` + `/api/v1/permissions/catalog`; capabilities/metrics `phase: 10.1`
|
||||
- [x] Permissions `delivery.drivers.*`; events `delivery.driver.*` via transactional outbox
|
||||
- [x] Alembic `0002_phase_101_drivers`; version **0.10.1.0**
|
||||
- [x] Tests: lifecycle, credentials/documents, tenant, architecture, migration, permissions, performance indexes, docs
|
||||
- [x] Docs: [delivery-phase-10-1.md](delivery-phase-10-1.md), [phase-handover/phase-10-1.md](phase-handover/phase-10-1.md)
|
||||
- [x] No fleet / dispatch / routing / tracking engines (deferred)
|
||||
|
||||
---
|
||||
|
||||
## Phase HC-Reg — Healthcare Platform Registration ✅
|
||||
|
||||
> Documentation-only registration. No business code, models, APIs, migrations, or repositories.
|
||||
|
||||
- [x] Phase roadmap 13.0–13.7 documented in [healthcare-roadmap.md](healthcare-roadmap.md)
|
||||
- [x] Planned modules registered in [module-registry.md](module-registry.md#healthcare)
|
||||
- [x] Phase area README: [phases/Healthcare/README.md](phases/Healthcare/README.md)
|
||||
- [x] Phase documents: 13.0 Foundation through 13.7 Delivery Integration
|
||||
- [x] Roadmap / progress / next-steps integration
|
||||
- [x] Port **8010** and `healthcare_db` reserved in registry
|
||||
- [x] No business services modified at registration time
|
||||
|
||||
---
|
||||
|
||||
## Phase 13.0 — Healthcare Platform Foundation ✅
|
||||
|
||||
- [x] Independent `backend/services/healthcare` (`healthcare_db`, port **8010**, version **0.13.0.0**, commercial product **Torbat Health**)
|
||||
- [x] Foundation aggregates: Clinic, Branch, Department, Doctor, Patient, HealthcareRole, HealthcarePermission, ExternalProviderConfig, HealthcareConfiguration, HealthcareSetting, HealthcareAuditLog, OutboxEvent
|
||||
- [x] Provider contracts only (Communication, CRM, Loyalty, Accounting, Delivery, Storage, AI, Identity)
|
||||
- [x] Publish-only events `healthcare.clinic.*`, `healthcare.doctor.*`, `healthcare.patient.*`, `healthcare.setting.*`
|
||||
- [x] Permissions `healthcare.*`; APIs under `/api/v1/clinics|branches|departments|doctors|patients|settings|…`
|
||||
- [x] Alembic `0001_initial`; compose + `.env.example` + postgres init wiring
|
||||
- [x] Tests: architecture, API, tenant, permissions, migration, dependency, security, docs
|
||||
- [x] Docs + handover: [phase-handover/phase-13-0.md](phase-handover/phase-13-0.md)
|
||||
- [x] No appointment/clinical/pharmacy/delivery engines
|
||||
|
||||
---
|
||||
|
||||
## Phase 13.1 — Healthcare Appointment Engine ✅
|
||||
|
||||
- [x] Aggregates: Schedule, AvailabilityWindow, AppointmentType, Appointment, WaitingListEntry
|
||||
- [x] Lifecycle: book, confirm, cancel, reschedule, no-show; conflict detection on overlapping slots
|
||||
- [x] Communication reminder via provider mock
|
||||
- [x] Alembic `0002_phase_131_appointments`; version **0.13.1.0**
|
||||
- [x] Events `healthcare.appointment.*`, `healthcare.schedule.*`
|
||||
- [x] Handover: [phase-handover/phase-13-1.md](phase-handover/phase-13-1.md)
|
||||
|
||||
---
|
||||
|
||||
## Phase 13.2 — Healthcare Doctor Panel ✅
|
||||
|
||||
- [x] VisitSession, VisitNote, doctor dashboard/queue APIs
|
||||
- [x] Visit lifecycle: start, finish, defer, call-next; finish → appointment completed
|
||||
- [x] Alembic `0003_phase_132_doctor_panel`; version **0.13.2.0**
|
||||
- [x] Handover: [phase-handover/phase-13-2.md](phase-handover/phase-13-2.md)
|
||||
|
||||
---
|
||||
|
||||
## Phase 13.3 — Healthcare Clinic Management ✅
|
||||
|
||||
- [x] ClinicService catalog, StaffAssignment, ClinicPolicy, OperatingHoursPolicy
|
||||
- [x] Policy validators (cancellation vs booking lead time)
|
||||
- [x] Alembic `0004_phase_133_clinic_management`; version **0.13.3.0**
|
||||
- [x] Handover: [phase-handover/phase-13-3.md](phase-handover/phase-13-3.md)
|
||||
|
||||
---
|
||||
|
||||
## Phase 13.4 — Healthcare Patient Portal ✅
|
||||
|
||||
- [x] Patient-scoped APIs under `/api/v1/patient-portal/*` (profile, appointments, documents, notification preferences)
|
||||
- [x] Alembic `0005_phase_134_patient_portal`; version **0.13.4.0**
|
||||
- [x] Handover: [phase-handover/phase-13-4.md](phase-handover/phase-13-4.md)
|
||||
|
||||
---
|
||||
|
||||
## Phase 13.5 — Healthcare Medical Record ✅
|
||||
|
||||
- [x] MedicalRecord, Encounter (finalize immutability), Condition, Allergy, MedicationStatement, ImmunizationRecord, access audit
|
||||
- [x] Alembic `0006_phase_135_medical_record`; version **0.13.5.0**
|
||||
- [x] Handover: [phase-handover/phase-13-5.md](phase-handover/phase-13-5.md)
|
||||
|
||||
---
|
||||
|
||||
## Phase 13.6 — Healthcare Pharmacy Network ✅
|
||||
|
||||
- [x] Pharmacy, PrescriptionOrder, PrescriptionLine, fulfillment lifecycle (accept → ready → picked_up / cancelled)
|
||||
- [x] Alembic `0007_phase_136_pharmacy_network`; version **0.13.6.0**
|
||||
- [x] Handover: [phase-handover/phase-13-6.md](phase-handover/phase-13-6.md)
|
||||
|
||||
---
|
||||
|
||||
## Phase 13.7 — Healthcare Delivery Integration ✅
|
||||
|
||||
- [x] DeliveryIntegrationRegistration, DeliveryJobIntent, DeliveryJobStatusSnapshot, webhook status ingest
|
||||
- [x] Delivery provider mock (no cross-service imports); Communication notify on milestones
|
||||
- [x] Alembic `0008_phase_137_delivery_integration`; version **0.13.7.0**
|
||||
- [x] **57 backend tests passing**; frontend `/healthcare` shell + BFF + catalog tile
|
||||
- [x] Handover: [phase-handover/phase-13-7.md](phase-handover/phase-13-7.md)
|
||||
- [x] Snapshot: [service-snapshots/healthcare.yaml](service-snapshots/healthcare.yaml)
|
||||
|
||||
### Healthcare Platform
|
||||
|
||||
- [x] Phases 13.0–13.7 complete (Foundation through Delivery Integration)
|
||||
|
||||
---
|
||||
|
||||
## Phase BB-Reg — Beauty Business Platform Registration ✅
|
||||
|
||||
> Documentation-only registration. No business code, models, APIs, migrations, or repositories.
|
||||
|
||||
- [x] Phase roadmap 14.0–14.7 documented in [beauty-business-roadmap.md](beauty-business-roadmap.md)
|
||||
- [x] Planned modules registered in [module-registry.md](module-registry.md#beauty_business)
|
||||
- [x] Phase area README: [phases/BeautyBusiness/README.md](phases/BeautyBusiness/README.md)
|
||||
- [x] Phase documents: 14.0 Foundation through 14.7 Marketing & Loyalty
|
||||
- [x] Roadmap / progress / next-steps integration
|
||||
- [x] Port **8011** and `beauty_business_db` reserved in registry (implementation deferred)
|
||||
- [x] Documented independence from Healthcare module (separate docs, phases, frontend, permissions, branding)
|
||||
- [x] No business services modified
|
||||
|
||||
---
|
||||
|
||||
## Phase 14.0 — Beauty Business Platform Foundation ✅
|
||||
|
||||
- [x] Independent `backend/services/beauty_business` (`beauty_business_db`, port **8011**, version **0.14.0.0** → **0.14.7.0**, commercial product **Torbat Beauty**)
|
||||
- [x] Foundation aggregates: BeautyOrganization (incl. `business_format`), BeautyBranch, BeautyRole, BeautyPermission, ExternalProviderConfig, BookingEngineRegistration, BeautyConfiguration, BeautySetting, BeautyAuditLog, OutboxEvent
|
||||
- [x] `BusinessFormat` enum: salon, barbershop, clinic, laser, skin_care, hair_treatment, spa
|
||||
- [x] Provider contracts only (CRM, Accounting, Loyalty, Communication, Delivery, Experience, Storage, AI, Identity)
|
||||
- [x] Publish-only events `beauty_business.*`; permissions `beauty_business.*`
|
||||
- [x] Alembic `0001_initial`; compose + `.env.example` wiring
|
||||
- [x] Tests: architecture, API, tenant, permissions, migration, dependency, security, docs
|
||||
- [x] No booking/catalog/customer engines in 14.0 scope
|
||||
|
||||
---
|
||||
|
||||
## Phase 14.1 — Beauty Booking Engine ✅
|
||||
|
||||
- [x] Aggregates: StaffSchedule, StaffAvailability, ScheduleException, AppointmentType, Appointment, AppointmentStatusHistory, WaitingListEntry
|
||||
- [x] Appointment lifecycle: confirm, check-in, complete, cancel, no-show
|
||||
- [x] Alembic `0002_phase_141_booking`; APIs under `/staff-schedules`, `/appointments`, `/waiting-list`, …
|
||||
- [x] Events `beauty_business.appointment.*`; permissions `beauty_business.appointments.*`, `beauty_business.schedules.*`
|
||||
|
||||
---
|
||||
|
||||
## Phase 14.2 — Beauty Salon Management ✅
|
||||
|
||||
- [x] Aggregates: TreatmentRoom, Station, BranchPolicy; optional `business_format` on branch
|
||||
- [x] Alembic `0003_phase_142_salon`; APIs `/treatment-rooms`, `/stations`, `/branch-policies`
|
||||
- [x] Events `beauty_business.room.*`, `beauty_business.station.*`
|
||||
|
||||
---
|
||||
|
||||
## Phase 14.3 — Beauty Service Catalog ✅
|
||||
|
||||
- [x] Aggregates: ServiceCategory, BeautyService (+ ServiceVariant, BranchServiceAssignment, ServiceMediaRef in schema)
|
||||
- [x] Alembic `0004_phase_143_catalog`; APIs `/service-categories`, `/services`
|
||||
- [x] Events `beauty_business.service.*`
|
||||
|
||||
---
|
||||
|
||||
## Phase 14.4 — Beauty Customer Management ✅
|
||||
|
||||
- [x] Aggregates: CustomerProfile (CRM ref), CustomerPreference, VisitHistoryEntry, CustomerDocumentRef
|
||||
- [x] Alembic `0005_phase_144_customers`; API `/customers`
|
||||
- [x] Events `beauty_business.customer.*`
|
||||
|
||||
---
|
||||
|
||||
## Phase 14.5 — Beauty Package & Membership ✅
|
||||
|
||||
- [x] Aggregates: PackageDefinition, CustomerPackage, MembershipPlan, CustomerMembership (+ lines/redemptions/lifecycle in schema)
|
||||
- [x] Alembic `0006_phase_145_packages`; APIs `/package-definitions`, `/membership-plans`
|
||||
- [x] Events `beauty_business.package.*`, `beauty_business.membership.*`
|
||||
|
||||
---
|
||||
|
||||
## Phase 14.6 — Beauty Staff & Commission ✅
|
||||
|
||||
- [x] Aggregates: StaffProfile, StaffSkill, StaffCertificate, CommissionRule, CommissionEntry
|
||||
- [x] Alembic `0007_phase_146_staff`; APIs `/staff`, `/commission-rules`
|
||||
- [x] Events `beauty_business.staff.*`, `beauty_business.commission.*`
|
||||
|
||||
---
|
||||
|
||||
## Phase 14.7 — Beauty Marketing & Loyalty Integration ✅
|
||||
|
||||
- [x] Aggregates: MarketingCampaignRef, LoyaltyIntegrationConfig, CommunicationIntegrationConfig, IntegrationDispatch
|
||||
- [x] Provider mocks for CRM, Loyalty, Communication, Accounting, Delivery, Experience
|
||||
- [x] Alembic `0008_phase_147_marketing`; APIs `/marketing-campaigns`, `/loyalty-integrations`, `/communication-integrations`, `/integration-dispatches`
|
||||
- [x] **30 backend tests passing**; frontend `/beauty` shell + BFF + catalog tile
|
||||
- [x] Handover: [phase-handover/phase-14-7.md](phase-handover/phase-14-7.md)
|
||||
- [x] Snapshot: [service-snapshots/beauty-business.yaml](service-snapshots/beauty-business.yaml)
|
||||
- [x] Independent from Healthcare (separate DB, port, frontend, permissions)
|
||||
|
||||
### Beauty Business Platform
|
||||
|
||||
- [x] Phases 14.0–14.7 complete (Foundation through Marketing & Loyalty Integration)
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [Next Steps](next-steps.md)
|
||||
@ -317,14 +866,65 @@ Remaining: Phase 5.12 AI UI blocked on AI provider; optional richer PDF export f
|
||||
- [CRM Phase 6.3](crm-phase-6-3.md)
|
||||
- [Loyalty Phase 7.0](loyalty-phase-7-0.md)
|
||||
- [Loyalty Phase 7.1](loyalty-phase-7-1.md)
|
||||
- [Loyalty Phase 7.2](loyalty-phase-7-2.md)
|
||||
- [Loyalty Phase 7.3](loyalty-phase-7-3.md)
|
||||
- [Loyalty Phase 7.4](loyalty-phase-7-4.md)
|
||||
- [Loyalty Phase 7.5](loyalty-phase-7-5.md)
|
||||
- [Loyalty Phase 7.6](loyalty-phase-7-6.md)
|
||||
- [Communication Phase 8](communication-phase-8.md)
|
||||
- [Communication Roadmap](communication-roadmap.md)
|
||||
- [Phase Handover 8](phase-handover/phase-8.md)
|
||||
- [Sports Center Phase 9.0](sports-center-phase-9-0.md)
|
||||
- [Sports Center Phase 9.1](sports-center-phase-9-1.md)
|
||||
- [Sports Center Phase 9.2](sports-center-phase-9-2.md)
|
||||
- [Sports Center Phase 9.6](sports-center-phase-9-6.md)
|
||||
- [Sports Center Phase 9.7](sports-center-phase-9-7.md)
|
||||
- [Phase Handover 9.0](phase-handover/phase-9-0.md)
|
||||
- [Phase Handover 9.1](phase-handover/phase-9-1.md)
|
||||
- [Phase Handover 9.2](phase-handover/phase-9-2.md)
|
||||
- [Phase Handover 9.6](phase-handover/phase-9-6.md)
|
||||
- [Phase Handover 9.7](phase-handover/phase-9-7.md)
|
||||
- [Sports Center Roadmap](sports-center-roadmap.md)
|
||||
- [AI Development Framework](ai-framework/README.md)
|
||||
- [Sports Center Service Snapshot](service-snapshots/sports-center.yaml)
|
||||
- [Delivery Roadmap](delivery-roadmap.md)
|
||||
- [Delivery Phase 10.0](delivery-phase-10-0.md)
|
||||
- [Delivery Phase 10.1](delivery-phase-10-1.md)
|
||||
- [Phase Handover DP-Reg](phase-handover/phase-dp-reg.md)
|
||||
- [Phase Handover 10.0](phase-handover/phase-10-0.md)
|
||||
- [Phase Handover 10.1](phase-handover/phase-10-1.md)
|
||||
- [Experience Roadmap](experience-roadmap.md)
|
||||
- [Experience Phase XP-Reg](experience-phase-xp-reg.md)
|
||||
- [Experience Phase 11.0](experience-phase-11-0.md)
|
||||
- [Experience Phase 11.1](experience-phase-11-1.md)
|
||||
- [Experience Phase 11.2](experience-phase-11-2.md)
|
||||
- [Experience Phase 11.3](experience-phase-11-3.md)
|
||||
- [Phase Handover XP-Reg](phase-handover/phase-xp-reg.md)
|
||||
- [Phase Handover 11.0](phase-handover/phase-11-0.md)
|
||||
- [Phase Handover 11.1](phase-handover/phase-11-1.md)
|
||||
- [AI / Enterprise Development Framework](ai-framework/README.md)
|
||||
- [Phase Handover AF-Enterprise](phase-handover/phase-af-enterprise.md)
|
||||
- [Phase Handover AF-Discovery](phase-handover/phase-af-discovery.md)
|
||||
- [ADR-013](architecture/adr/ADR-013.md)
|
||||
- [ADR-018](architecture/adr/ADR-018.md)
|
||||
- [ADR-019](architecture/adr/ADR-019.md)
|
||||
- [ADR-014](architecture/adr/ADR-014.md)
|
||||
- [ADR-015](architecture/adr/ADR-015.md)
|
||||
- [ADR-016](architecture/adr/ADR-016.md)
|
||||
- [ADR-017](architecture/adr/ADR-017.md)
|
||||
- [Hospitality Phase 12.0](hospitality-phase-12-0.md)
|
||||
- [Hospitality Phase 12.1](hospitality-phase-12-1.md)
|
||||
- [Hospitality Phase 12.2](hospitality-phase-12-2.md)
|
||||
- [Hospitality Phase 12.3](hospitality-phase-12-3.md)
|
||||
- [Hospitality Roadmap](hospitality-roadmap.md)
|
||||
- [Phase Handover 12.0](phase-handover/phase-12-0.md)
|
||||
- [Phase Handover 12.1](phase-handover/phase-12-1.md)
|
||||
- [Phase Handover 12.2](phase-handover/phase-12-2.md)
|
||||
- [Phase Handover 12.3](phase-handover/phase-12-3.md)
|
||||
- [Healthcare Roadmap](healthcare-roadmap.md)
|
||||
- [Healthcare Snapshot](service-snapshots/healthcare.yaml)
|
||||
- [Phase Handover 13.7](phase-handover/phase-13-7.md)
|
||||
- [Healthcare Phase Area](phases/Healthcare/README.md)
|
||||
- [Beauty Business Roadmap](beauty-business-roadmap.md)
|
||||
- [Beauty Business Phase Area](phases/BeautyBusiness/README.md)
|
||||
- [Beauty Business Snapshot](service-snapshots/beauty-business.yaml)
|
||||
- [Phase Handover 14.7](phase-handover/phase-14-7.md)
|
||||
|
||||
@ -58,12 +58,17 @@ Base: Loyalty service port `8004` (`/api/v1`) — public host `loyalty.torbatyar
|
||||
| Programs | `/api/v1/programs` (+ soft delete) |
|
||||
| Tiers | `/api/v1/tiers` (+ soft delete) |
|
||||
| Members | `/api/v1/members` (+ `/enroll`, `/activate`, `/renew`, `/freeze`, `/resume`, `/cancel`, `/expire`, `/transfer`, `/lifecycle`, soft delete) |
|
||||
| Point accounts | `/api/v1/point-accounts` (+ soft delete; no balance fields) |
|
||||
| Rewards | `/api/v1/rewards` (+ soft delete) |
|
||||
| Campaigns | `/api/v1/campaigns` (+ soft delete) |
|
||||
| Point accounts | `/api/v1/point-accounts` (+ soft delete; `/balance`, `/ledger`, `/earn`, `/redeem`, `/adjust`, `/expire-points`) |
|
||||
| Rewards | `/api/v1/rewards` (+ soft delete; `/redeem`, `/redemptions`) |
|
||||
| Redemptions | `/api/v1/redemptions` (+ `/fulfill`, `/cancel`) |
|
||||
| Campaigns | `/api/v1/campaigns` (+ soft delete; `/schedule`, `/activate`, `/pause`, `/resume`, `/expire`, `/rules`, `/apply`, `/applications`) |
|
||||
| Referral programs | `/api/v1/referral-programs` |
|
||||
| Referral codes | `/api/v1/referral-codes` |
|
||||
| Referrals | `/api/v1/referrals` (+ `/attribute`, `/convert`, `/reward`, `/cancel`) |
|
||||
| Wallets | `/api/v1/wallets` (+ `/balance`, `/ledger`, `/credit`, `/debit`, `/adjust`, `/transfer`, soft delete) |
|
||||
| Audit | `/api/v1/audit` |
|
||||
|
||||
Phase docs: [loyalty-phase-7-0.md](../loyalty-phase-7-0.md), [loyalty-phase-7-1.md](../loyalty-phase-7-1.md)
|
||||
Phase docs: [loyalty-phase-7-0.md](../loyalty-phase-7-0.md) … [loyalty-phase-7-6.md](../loyalty-phase-7-6.md)
|
||||
|
||||
## Future Services
|
||||
|
||||
|
||||
@ -96,12 +96,27 @@ Full list: `backend/services/crm/app/events/types.py`
|
||||
| `loyalty.point_account.opened` | Point account opened |
|
||||
| `loyalty.point_account.updated` | Point account updated |
|
||||
| `loyalty.point_account.deleted` | Point account soft-deleted |
|
||||
| `loyalty.points.earned` | Points earned on ledger |
|
||||
| `loyalty.points.redeemed` | Points redeemed on ledger |
|
||||
| `loyalty.points.adjusted` | Points adjusted on ledger |
|
||||
| `loyalty.points.expired` | Points expired on ledger |
|
||||
| `loyalty.reward.created` | Reward catalog item created |
|
||||
| `loyalty.reward.updated` | Reward catalog item updated |
|
||||
| `loyalty.reward.deleted` | Reward soft-deleted |
|
||||
| `loyalty.reward.redeemed` | Reward redeemed (points debited) |
|
||||
| `loyalty.reward.fulfilled` | Redemption fulfilled |
|
||||
| `loyalty.reward.redemption_cancelled` | Redemption cancelled / refunded |
|
||||
| `loyalty.campaign.created` | Campaign created |
|
||||
| `loyalty.campaign.updated` | Campaign updated |
|
||||
| `loyalty.campaign.deleted` | Campaign soft-deleted |
|
||||
| `loyalty.campaign.applied` | Campaign applied (points granted) |
|
||||
| `loyalty.referral.rewarded` | Referral bonuses granted |
|
||||
| `loyalty.wallet.opened` | Wallet account opened |
|
||||
| `loyalty.wallet.credited` | Wallet credited |
|
||||
| `loyalty.wallet.debited` | Wallet debited |
|
||||
| `loyalty.wallet.adjusted` | Wallet adjusted |
|
||||
| `loyalty.wallet.transferred` | Wallet transfer |
|
||||
| `loyalty.wallet.closed` | Wallet soft-closed |
|
||||
|
||||
Full list: `backend/services/loyalty/app/events/types.py`
|
||||
|
||||
@ -134,3 +149,32 @@ When adding events: update this catalog, module registry producers/consumers, an
|
||||
- [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 |
|
||||
|
||||
@ -16,18 +16,38 @@
|
||||
3. ~~CRM Core Business Entities~~ (Phase 6.1 done)
|
||||
4. ~~CRM Enterprise Sales Process Engine~~ (Phase 6.2 done)
|
||||
5. ~~CRM Enterprise Sales Collaboration~~ (Phase 6.3 done — CRM Core Platform)
|
||||
8. ~~Loyalty Service Foundation~~ (Phase 7.0 done)
|
||||
9. ~~Loyalty Membership Engine~~ (Phase 7.1 done)
|
||||
10. Loyalty Point Engine (Phase 7.2)
|
||||
11. ~~Enterprise Communication Platform~~ (Phase 8.0–8.10 done)
|
||||
12. ~~Sports Center Platform Foundation~~ (Phase 9.0 done)
|
||||
13. ~~Sports Center Membership Catalog~~ (Phase 9.1 done)
|
||||
14. ~~Sports Center Member Management~~ (Phase 9.2 done)
|
||||
15. Sports Center Coach & Staff Management (Phase 9.3)
|
||||
16. Further CRM / cross-module work only when explicitly scoped
|
||||
17. Notification service as thin consumer of Communication (or merge later)
|
||||
18. File storage + S3 provider
|
||||
19. Payment gateway for subscriptions and commerce
|
||||
6. ~~Loyalty Service Foundation~~ (Phase 7.0 done)
|
||||
7. ~~Loyalty Membership Engine~~ (Phase 7.1 done)
|
||||
8. ~~Loyalty Point Engine~~ (Phase 7.2 done)
|
||||
8a. ~~Loyalty Rewards Engine~~ (Phase 7.3 done)
|
||||
8b. ~~Loyalty Campaign Engine~~ (Phase 7.4 done)
|
||||
8c. ~~Loyalty Referral Engine~~ (Phase 7.5 done)
|
||||
8d. ~~Loyalty Wallet~~ (Phase 7.6 done)
|
||||
8e. Loyalty Gift Card Platform (Phase 7.7)
|
||||
9. ~~Enterprise Communication Platform~~ (Phase 8.0–8.10 done — **Production Ready SMS MVP**; future channels → [communication-roadmap.md](communication-roadmap.md))
|
||||
10. ~~Sports Center Platform Foundation~~ (Phase 9.0 done)
|
||||
11. ~~Sports Center Membership Catalog~~ (Phase 9.1 done)
|
||||
12. ~~Sports Center Member Management~~ (Phase 9.2 done)
|
||||
13. Sports Center Coach & Staff Management (Phase 9.3)
|
||||
14. ~~Delivery & Fleet Platform Registration~~ (DP-Reg done)
|
||||
15. ~~Delivery Platform Foundation~~ (Phase 10.0 done)
|
||||
16. ~~Delivery Driver Management~~ (Phase 10.1 done)
|
||||
17. Delivery Fleet & Vehicle Types through Enterprise Validation (Phases 10.2–10.10)
|
||||
18. ~~Experience Platform Registration~~ (XP-Reg done)
|
||||
19. ~~Experience Platform Foundation~~ (Phase 11.0 done)
|
||||
20. ~~Experience Sites & Page Resources~~ (Phase 11.1 done)
|
||||
21. ~~Experience Component Library & Versioning~~ (Phase 11.2 done)
|
||||
22. ~~Experience Theme & Layout Engine~~ (Phase 11.3 done)
|
||||
23. Experience Template System (Phase 11.4)
|
||||
24. Experience Localization through Enterprise Validation (Phases 11.5–11.10)
|
||||
25. Further CRM / cross-module work only when explicitly scoped
|
||||
26. Notification service as thin consumer of Communication (or merge later)
|
||||
27. File storage + S3 provider
|
||||
28. Payment gateway for subscriptions and commerce
|
||||
29. ~~Healthcare Platform Registration~~ (HC-Reg done — docs only)
|
||||
30. Healthcare Platform Foundation through Delivery Integration (Phases 13.0–13.7)
|
||||
31. ~~Beauty Business Platform Registration~~ (BB-Reg done — docs only)
|
||||
32. Beauty Business Platform Foundation through Marketing & Loyalty (Phases 14.0–14.7)
|
||||
|
||||
## Longer Term
|
||||
|
||||
@ -43,15 +63,24 @@
|
||||
- [Accounting](phases/Accounting/README.md)
|
||||
- [CRM](phases/CRM/README.md)
|
||||
- [Loyalty](phases/Loyalty/README.md)
|
||||
- [Communication Roadmap](communication-roadmap.md)
|
||||
- [Restaurant](phases/Restaurant/README.md)
|
||||
- [Sports Center](phases/SportsCenter/README.md)
|
||||
- [Sports Center Roadmap](sports-center-roadmap.md)
|
||||
- [Delivery](phases/Delivery/README.md)
|
||||
- [Delivery Roadmap](delivery-roadmap.md)
|
||||
- [Experience](phases/Experience/README.md)
|
||||
- [Experience Roadmap](experience-roadmap.md)
|
||||
- [Marketplace](phases/Marketplace/README.md)
|
||||
- [Automation](phases/Automation/README.md)
|
||||
- [AI](phases/AI/README.md)
|
||||
- [Future](phases/Future/README.md)
|
||||
- [Healthcare](phases/Healthcare/README.md)
|
||||
- [Healthcare Roadmap](healthcare-roadmap.md)
|
||||
- [Beauty Business](phases/BeautyBusiness/README.md)
|
||||
- [Beauty Business Roadmap](beauty-business-roadmap.md)
|
||||
|
||||
Implementation of every future phase must follow the [AI Development Framework](ai-framework/README.md).
|
||||
Implementation of every future phase must follow the [Enterprise / AI Development Framework](ai-framework/README.md) ([ADR-013](architecture/adr/ADR-013.md), [ADR-018](architecture/adr/ADR-018.md), [ADR-019](architecture/adr/ADR-019.md)). Production readiness is automatic via Enterprise Phase Discovery, Definition of Done, mandatory artifacts, and enterprise completeness — no extra prompt required.
|
||||
|
||||
## Related Documents
|
||||
|
||||
@ -62,3 +91,9 @@ Implementation of every future phase must follow the [AI Development Framework](
|
||||
- [Phase Manifest](ai-framework/phase-manifest.yaml)
|
||||
- [Sports Center Roadmap](sports-center-roadmap.md)
|
||||
- [ADR-014](architecture/adr/ADR-014.md)
|
||||
- [ADR-015](architecture/adr/ADR-015.md)
|
||||
- [Experience Roadmap](experience-roadmap.md)
|
||||
- [Communication Roadmap](communication-roadmap.md)
|
||||
- [ADR-016](architecture/adr/ADR-016.md)
|
||||
- [Healthcare Roadmap](healthcare-roadmap.md)
|
||||
- [Beauty Business Roadmap](beauty-business-roadmap.md)
|
||||
|
||||
112
docs/service-snapshots/loyalty.yaml
Normal file
112
docs/service-snapshots/loyalty.yaml
Normal 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"
|
||||
@ -82,6 +82,7 @@ NEXT_PUBLIC_KEYCLOAK_REALM=superapp
|
||||
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend
|
||||
NEXT_PUBLIC_ACCOUNTING_API_URL=https://accounting.torbatyar.ir
|
||||
NEXT_PUBLIC_CRM_API_URL=https://crm.torbatyar.ir
|
||||
NEXT_PUBLIC_HEALTHCARE_API_URL=https://healthcare.torbatyar.ir
|
||||
|
||||
ACCOUNTING_SERVICE_URL=http://accounting-service:8002
|
||||
ACCOUNTING_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/accounting_db
|
||||
@ -97,6 +98,12 @@ LOYALTY_SERVICE_URL=http://loyalty-service:8004
|
||||
LOYALTY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/loyalty_db
|
||||
LOYALTY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/loyalty_db
|
||||
LOYALTY_SERVICE_NAME=loyalty-service
|
||||
|
||||
HEALTHCARE_SERVICE_URL=http://healthcare-service:8010
|
||||
HEALTHCARE_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/healthcare_db
|
||||
HEALTHCARE_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/healthcare_db
|
||||
HEALTHCARE_SERVICE_NAME=healthcare-service
|
||||
|
||||
KEYCLOAK_SERVER_URL=http://keycloak:8080
|
||||
KEYCLOAK_REALM=superapp
|
||||
|
||||
|
||||
@ -33,11 +33,23 @@ upstream torbatyar_loyalty {
|
||||
server 192.168.10.162:8004;
|
||||
keepalive 16;
|
||||
}
|
||||
upstream torbatyar_healthcare {
|
||||
server 192.168.10.162:8010;
|
||||
keepalive 16;
|
||||
}
|
||||
upstream torbatyar_beauty {
|
||||
server 192.168.10.162:8011;
|
||||
keepalive 16;
|
||||
}
|
||||
upstream torbatyar_hospitality {
|
||||
server 192.168.10.162:8009;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
# Force HTTPS for platform hosts
|
||||
server {
|
||||
listen 192.168.10.156:80;
|
||||
server_name torbatyar.ir www.torbatyar.ir api.torbatyar.ir identity.torbatyar.ir auth.torbatyar.ir accounting.torbatyar.ir crm.torbatyar.ir loyalty.torbatyar.ir;
|
||||
server_name torbatyar.ir www.torbatyar.ir api.torbatyar.ir identity.torbatyar.ir auth.torbatyar.ir accounting.torbatyar.ir crm.torbatyar.ir loyalty.torbatyar.ir healthcare.torbatyar.ir beauty.torbatyar.ir hospitality.torbatyar.ir;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
@ -80,8 +92,8 @@ server {
|
||||
listen 192.168.10.156:443 ssl;
|
||||
server_name torbatyar.ir www.torbatyar.ir *.torbatyar.ir;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem;
|
||||
ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
@ -104,8 +116,8 @@ server {
|
||||
listen 192.168.10.156:443 ssl;
|
||||
server_name api.torbatyar.ir;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem;
|
||||
ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
@ -125,8 +137,8 @@ server {
|
||||
listen 192.168.10.156:443 ssl;
|
||||
server_name identity.torbatyar.ir;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem;
|
||||
ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
@ -146,8 +158,8 @@ server {
|
||||
listen 192.168.10.156:443 ssl;
|
||||
server_name auth.torbatyar.ir;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem;
|
||||
ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
@ -170,8 +182,8 @@ server {
|
||||
listen 192.168.10.156:443 ssl;
|
||||
server_name accounting.torbatyar.ir;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem;
|
||||
ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
@ -191,8 +203,8 @@ server {
|
||||
listen 192.168.10.156:443 ssl;
|
||||
server_name crm.torbatyar.ir;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem;
|
||||
ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
@ -212,8 +224,8 @@ server {
|
||||
listen 192.168.10.156:443 ssl;
|
||||
server_name loyalty.torbatyar.ir;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem;
|
||||
ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
@ -228,3 +240,66 @@ server {
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 192.168.10.156:443 ssl;
|
||||
server_name healthcare.torbatyar.ir;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://torbatyar_healthcare;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 600s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 192.168.10.156:443 ssl;
|
||||
server_name beauty.torbatyar.ir;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://torbatyar_beauty;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 600s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 192.168.10.156:443 ssl;
|
||||
server_name hospitality.torbatyar.ir;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/torbatyar.ir-0001/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-0001/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://torbatyar_hospitality;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 600s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,3 +4,6 @@ CREATE DATABASE crm_db;
|
||||
CREATE DATABASE communication_db;
|
||||
CREATE DATABASE loyalty_db;
|
||||
CREATE DATABASE sports_center_db;
|
||||
CREATE DATABASE hospitality_db;
|
||||
CREATE DATABASE experience_db;
|
||||
CREATE DATABASE healthcare_db;
|
||||
|
||||
306
scripts/deploy_loyalty_prod.py
Normal file
306
scripts/deploy_loyalty_prod.py
Normal file
@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deploy Loyalty service (Phases 7.0–7.6) to production without wiping .env secrets."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
import paramiko
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP_HOST = "192.168.10.162"
|
||||
APP_USER = "morteza"
|
||||
APP_PASS = "Moli@5404"
|
||||
NGX_HOST = "192.168.10.156"
|
||||
NGX_USER = "torbatyaruser"
|
||||
NGX_PASS = "Moli@5404"
|
||||
REMOTE_DIR = "/home/morteza/torbatyar"
|
||||
|
||||
EXCLUDE_DIRS = {
|
||||
".git",
|
||||
"node_modules",
|
||||
".next",
|
||||
".venv",
|
||||
"venv",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
".idea",
|
||||
".vscode",
|
||||
"agent-transcripts",
|
||||
}
|
||||
EXCLUDE_FILES = {".DS_Store", "Thumbs.db", ".env"}
|
||||
|
||||
ENV_UPSERT = {
|
||||
"LOYALTY_SERVICE_URL": "http://loyalty-service:8004",
|
||||
"LOYALTY_DATABASE_URL": "postgresql+asyncpg://superapp:superapp_password@postgres:5432/loyalty_db",
|
||||
"LOYALTY_DATABASE_URL_SYNC": "postgresql+psycopg://superapp:superapp_password@postgres:5432/loyalty_db",
|
||||
"LOYALTY_SERVICE_NAME": "loyalty-service",
|
||||
"KEYCLOAK_SERVER_URL": "http://keycloak:8080",
|
||||
"KEYCLOAK_REALM": "superapp",
|
||||
}
|
||||
|
||||
|
||||
def connect(host: str, user: str, password: str) -> paramiko.SSHClient:
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
c.connect(
|
||||
host,
|
||||
username=user,
|
||||
password=password,
|
||||
timeout=30,
|
||||
allow_agent=False,
|
||||
look_for_keys=False,
|
||||
)
|
||||
return c
|
||||
|
||||
|
||||
def run(
|
||||
client: paramiko.SSHClient,
|
||||
cmd: str,
|
||||
*,
|
||||
sudo: bool = False,
|
||||
password: str = "",
|
||||
timeout: int = 600,
|
||||
) -> tuple[int, str]:
|
||||
cmd = cmd.replace("\r\n", "\n").replace("\r", "\n").strip() + "\n"
|
||||
b64 = base64.b64encode(cmd.encode()).decode()
|
||||
if sudo:
|
||||
full = f"echo {password!r} | sudo -S -p '' bash -c 'echo {b64} | base64 -d | bash'"
|
||||
else:
|
||||
full = f"bash -lc 'echo {b64} | base64 -d | bash'"
|
||||
print(
|
||||
f"\n>>> {'[sudo] ' if sudo else ''}{cmd[:220].replace(chr(10), ' | ')}"
|
||||
f"{'...' if len(cmd) > 220 else ''}"
|
||||
)
|
||||
_stdin, stdout, stderr = client.exec_command(full, timeout=timeout, get_pty=True)
|
||||
out = stdout.read().decode("utf-8", errors="replace")
|
||||
err = stderr.read().decode("utf-8", errors="replace")
|
||||
code = stdout.channel.recv_exit_status()
|
||||
text = out + (("\n" + err) if err.strip() else "")
|
||||
printable = text[-5000:] if len(text) > 5000 else text
|
||||
try:
|
||||
print(printable)
|
||||
except UnicodeEncodeError:
|
||||
print(printable.encode("ascii", "replace").decode("ascii"))
|
||||
return code, text
|
||||
|
||||
|
||||
def should_exclude(path: Path) -> bool:
|
||||
parts = set(path.parts)
|
||||
if parts & EXCLUDE_DIRS:
|
||||
return True
|
||||
if path.name in EXCLUDE_FILES:
|
||||
return True
|
||||
if path.suffix in {".pyc", ".log"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def make_tarball() -> Path:
|
||||
out = ROOT / "scripts" / "_deploy_loyalty_bundle.tar.gz"
|
||||
print(f"Creating tarball {out} ...")
|
||||
with tarfile.open(out, "w:gz") as tar:
|
||||
for dirpath, dirnames, filenames in os.walk(ROOT):
|
||||
p = Path(dirpath)
|
||||
dirnames[:] = [
|
||||
d for d in dirnames if d not in EXCLUDE_DIRS and not d.startswith(".cursor")
|
||||
]
|
||||
rel_dir = p.relative_to(ROOT)
|
||||
if should_exclude(rel_dir) and rel_dir != Path("."):
|
||||
continue
|
||||
for name in filenames:
|
||||
fp = p / name
|
||||
rel = fp.relative_to(ROOT)
|
||||
if should_exclude(rel):
|
||||
continue
|
||||
if rel.as_posix().startswith("scripts/_deploy"):
|
||||
continue
|
||||
tar.add(fp, arcname=str(rel).replace("\\", "/"))
|
||||
print(f"Tarball size: {out.stat().st_size / 1024 / 1024:.1f} MB")
|
||||
return out
|
||||
|
||||
|
||||
def upload_file(client: paramiko.SSHClient, local: Path, remote: str) -> None:
|
||||
sftp = client.open_sftp()
|
||||
print(f"Uploading {local} -> {remote}")
|
||||
sftp.put(str(local), remote)
|
||||
sftp.close()
|
||||
|
||||
|
||||
def put_text_sudo(
|
||||
client: paramiko.SSHClient, password: str, remote: str, content: str
|
||||
) -> None:
|
||||
b64 = base64.b64encode(content.encode()).decode()
|
||||
cmd = (
|
||||
f"mkdir -p $(dirname {remote!r}) && echo {b64} | base64 -d > {remote!r} "
|
||||
f"&& chmod 644 {remote!r}"
|
||||
)
|
||||
code, _ = run(client, cmd, sudo=True, password=password, timeout=60)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"failed writing {remote}")
|
||||
|
||||
|
||||
def merge_env_remote(client: paramiko.SSHClient) -> None:
|
||||
py = f"""
|
||||
from pathlib import Path
|
||||
p = Path({REMOTE_DIR!r}) / '.env'
|
||||
text = p.read_text(encoding='utf-8') if p.exists() else ''
|
||||
lines = text.splitlines()
|
||||
keys = {ENV_UPSERT!r}
|
||||
seen = set()
|
||||
out = []
|
||||
for line in lines:
|
||||
if not line.strip() or line.lstrip().startswith('#') or '=' not in line:
|
||||
out.append(line)
|
||||
continue
|
||||
k = line.split('=', 1)[0].strip()
|
||||
if k in keys:
|
||||
out.append(f"{{k}}={{keys[k]}}")
|
||||
seen.add(k)
|
||||
else:
|
||||
out.append(line)
|
||||
for k, v in keys.items():
|
||||
if k not in seen:
|
||||
out.append(f"{{k}}={{v}}")
|
||||
p.write_text('\\n'.join(out).rstrip() + '\\n', encoding='utf-8')
|
||||
print('ENV_MERGED', len(keys))
|
||||
"""
|
||||
b64 = base64.b64encode(py.encode()).decode()
|
||||
code, _ = run(
|
||||
client,
|
||||
f"python3 -c \"import base64; exec(base64.b64decode('{b64}').decode())\"",
|
||||
)
|
||||
if code != 0:
|
||||
raise RuntimeError("env merge failed")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tarball = make_tarball()
|
||||
|
||||
app = connect(APP_HOST, APP_USER, APP_PASS)
|
||||
try:
|
||||
run(app, f"mkdir -p {REMOTE_DIR}")
|
||||
upload_file(app, tarball, "/tmp/torbatyar_loyalty_bundle.tar.gz")
|
||||
code, _ = run(
|
||||
app,
|
||||
f"""
|
||||
set -e
|
||||
cd {REMOTE_DIR}
|
||||
if [ -f .env ]; then cp .env /tmp/torbatyar.env.bak; fi
|
||||
chown -R {APP_USER}:{APP_USER} {REMOTE_DIR}/backend {REMOTE_DIR}/docs {REMOTE_DIR}/docker-compose.yml {REMOTE_DIR}/infrastructure 2>/dev/null || true
|
||||
chmod -R u+rwX {REMOTE_DIR}/backend {REMOTE_DIR}/docs 2>/dev/null || true
|
||||
tar --overwrite -xzf /tmp/torbatyar_loyalty_bundle.tar.gz -C {REMOTE_DIR}
|
||||
if [ -f /tmp/torbatyar.env.bak ]; then cp /tmp/torbatyar.env.bak .env; fi
|
||||
chown -R {APP_USER}:{APP_USER} {REMOTE_DIR}/backend {REMOTE_DIR}/docs 2>/dev/null || true
|
||||
rm -f /tmp/torbatyar_loyalty_bundle.tar.gz
|
||||
ls -la {REMOTE_DIR}/backend/services/loyalty | head
|
||||
""",
|
||||
sudo=True,
|
||||
password=APP_PASS,
|
||||
timeout=300,
|
||||
)
|
||||
if code != 0:
|
||||
raise RuntimeError("extract failed")
|
||||
|
||||
merge_env_remote(app)
|
||||
|
||||
run(
|
||||
app,
|
||||
f"""
|
||||
set -e
|
||||
cd {REMOTE_DIR}
|
||||
sg docker -c "docker compose exec -T postgres psql -U superapp -d postgres -tc \\"SELECT 1 FROM pg_database WHERE datname='loyalty_db'\\" | grep -q 1 || docker compose exec -T postgres psql -U superapp -d postgres -c 'CREATE DATABASE loyalty_db;'"
|
||||
""",
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
code, _ = run(
|
||||
app,
|
||||
f"""
|
||||
set -e
|
||||
cd {REMOTE_DIR}
|
||||
sg docker -c 'docker compose up -d --build loyalty-service'
|
||||
sg docker -c 'docker compose ps'
|
||||
sg docker -c 'docker compose logs --tail=80 loyalty-service'
|
||||
""",
|
||||
timeout=1800,
|
||||
)
|
||||
if code != 0:
|
||||
code, _ = run(
|
||||
app,
|
||||
f"""
|
||||
set -e
|
||||
cd {REMOTE_DIR}
|
||||
docker compose up -d --build loyalty-service
|
||||
docker compose ps
|
||||
docker compose logs --tail=120 loyalty-service
|
||||
""",
|
||||
sudo=True,
|
||||
password=APP_PASS,
|
||||
timeout=1800,
|
||||
)
|
||||
if code != 0:
|
||||
raise RuntimeError("compose up failed")
|
||||
|
||||
code, health = run(
|
||||
app,
|
||||
"""
|
||||
set -e
|
||||
for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
|
||||
if curl -sf http://127.0.0.1:8004/health; then
|
||||
echo
|
||||
echo LOYALTY_HEALTH_OK
|
||||
exit 0
|
||||
fi
|
||||
sleep 4
|
||||
done
|
||||
curl -sv http://127.0.0.1:8004/health || true
|
||||
docker compose -f /home/morteza/torbatyar/docker-compose.yml logs --tail=80 loyalty-service || true
|
||||
exit 1
|
||||
""",
|
||||
timeout=180,
|
||||
)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Loyalty health failed: {health}")
|
||||
finally:
|
||||
app.close()
|
||||
|
||||
ngx = connect(NGX_HOST, NGX_USER, NGX_PASS)
|
||||
try:
|
||||
conf = (ROOT / "infrastructure" / "nginx" / "torbatyar.ir.conf").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
put_text_sudo(ngx, NGX_PASS, "/etc/nginx/conf.d/torbatyar.ir.conf", conf)
|
||||
certbot = r"""
|
||||
set -e
|
||||
mkdir -p /var/www/html
|
||||
certbot certonly --webroot -w /var/www/html \
|
||||
-d torbatyar.ir -d www.torbatyar.ir \
|
||||
-d api.torbatyar.ir -d identity.torbatyar.ir -d auth.torbatyar.ir \
|
||||
-d accounting.torbatyar.ir -d crm.torbatyar.ir -d loyalty.torbatyar.ir \
|
||||
--non-interactive --agree-tos --email support@torbatyar.ir --expand || true
|
||||
nginx -t && systemctl reload nginx && echo NGINX_RELOADED
|
||||
"""
|
||||
code, _ = run(ngx, certbot, sudo=True, password=NGX_PASS, timeout=300)
|
||||
if code != 0:
|
||||
raise RuntimeError("nginx reload / certbot failed")
|
||||
finally:
|
||||
ngx.close()
|
||||
|
||||
print("\n=== LOYALTY DEPLOY FINISHED ===")
|
||||
print("API: https://loyalty.torbatyar.ir/health")
|
||||
print("Local: http://192.168.10.162:8004/health")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc:
|
||||
print(f"FATAL: {exc}", file=sys.stderr)
|
||||
raise
|
||||
Loading…
Reference in New Issue
Block a user