# Phase 7.1 — Membership Engine | Field | Value | | --- | --- | | Identifier | `loyalty-7.1` | | Status | Complete | | Module | loyalty | | Service | `loyalty-service` | | Version | `0.7.1.0` | | Database | `loyalty_db` | | Depends On | Phase 7.0 | | ADR(s) | ADR-001, ADR-003, ADR-006, ADR-011 | | Manifest | [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | ## Objective Deliver the Membership Engine: lifecycle state machine, eligibility transitions, append-only lifecycle history, program cascade policy, and lifecycle APIs/events — without points ledger, rewards redemption, or campaign engines. ## Scope ### In Scope - Member lifecycle: activate, renew, freeze, resume, cancel, expire, transfer - Enroll path hardened with term / expiry and lifecycle events - `MembershipLifecycleEvent` append-only history - Cascade policy: reject program soft-delete while blocking members exist - Permissions, events, validators, migration `0002_phase_71_membership` - Tests + documentation + handover ### Out of Scope - Point ledger / balances (7.2) - Rewards redemption (7.3) - Campaign engine (7.4+) - Wallet / gift cards / partner network ## Cascade Policy Soft-deleting a `LoyaltyProgram` is **rejected** (`409 program_has_active_members`) when any non-deleted member exists in statuses: `pending`, `active`, `frozen`, `suspended`, `expired`. Cancelled / transferred / closed members do not block. ## State Machine | Action | Allowed from | To | | --- | --- | --- | | enroll / activate | pending (activate also expired) | active | | renew | active, expired, frozen, suspended | active | | freeze | active | frozen | | resume | frozen, suspended | active | | cancel | pending, active, frozen, suspended, expired | cancelled | | expire | active, frozen, suspended | expired | | transfer | active, frozen, suspended | transferred (+ new active member in target program) | Terminal: `cancelled`, `transferred`, `closed`. ## Models | Entity | Soft delete | Audit | Tenant | | --- | --- | --- | --- | | Member (extended fields) | Yes | Yes | Yes | | MembershipLifecycleEvent | No (append-only) | Via fields | Yes | New member fields: `activated_at`, `membership_started_at`, `membership_expires_at`, `frozen_at`, `freeze_reason`, `cancelled_at`, `cancel_reason`, `expired_at`, `transferred_to_member_id`. ## APIs | Method | Path | Permission | | --- | --- | --- | | POST | `/api/v1/members/{id}/activate` | `loyalty.members.activate` | | POST | `/api/v1/members/{id}/renew` | `loyalty.members.renew` | | POST | `/api/v1/members/{id}/freeze` | `loyalty.members.freeze` | | POST | `/api/v1/members/{id}/resume` | `loyalty.members.resume` | | POST | `/api/v1/members/{id}/cancel` | `loyalty.members.cancel` | | POST | `/api/v1/members/{id}/expire` | `loyalty.members.expire` | | POST | `/api/v1/members/{id}/transfer` | `loyalty.members.transfer` | | GET | `/api/v1/members/{id}/lifecycle` | `loyalty.members.lifecycle.view` | Existing create / enroll / update / delete remain compatible (additive fields). ## Events | Event | When | | --- | --- | | `loyalty.member.activated` | Activate / enroll activation | | `loyalty.member.renewed` | Renew | | `loyalty.member.frozen` | Freeze | | `loyalty.member.resumed` | Resume | | `loyalty.member.cancelled` | Cancel | | `loyalty.member.expired` | Expire | | `loyalty.member.transferred` | Transfer (source) | ## Migration | Item | Detail | | --- | --- | | Alembic | `0002_phase_71_membership` (down_revision `0001_initial`) | | Breaking | None — additive columns + new table | | Backfill | None required | ## Tests Command: `cd backend/services/loyalty && pytest -q` → **59 passed** ## Known Limitations - No automatic expire scheduler (API-driven expire only) - Transfer copies profile/contact; does not move point accounts (7.2) - JWT↔tenant binding remains platform-wide ## Next Phase Phase 7.2 — Point Engine (immutable ledger) ## Related Documents - [Handover](phase-handover/phase-7-1.md) - [Phase 7.0](loyalty-phase-7-0.md) - [ADR-011](architecture/adr/ADR-011.md) - [Module Registry](module-registry.md#loyalty)