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