Compare commits
2 Commits
8cd09f3f89
...
4a60d75a13
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a60d75a13 | |||
| e98eddc168 |
64
app/modules/loyalty/docs/monitoring.md
Normal file
64
app/modules/loyalty/docs/monitoring.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Loyalty Module — Monitoring & Alerting
|
||||||
|
|
||||||
|
## Alert Definitions
|
||||||
|
|
||||||
|
### P0 — Page (immediate action required)
|
||||||
|
|
||||||
|
| Alert | Condition | Action |
|
||||||
|
|-------|-----------|--------|
|
||||||
|
| **Expiration task stale** | `loyalty.expire_points` last success > 26 hours ago | Check Celery worker health, inspect task logs |
|
||||||
|
| **Google Wallet service down** | Wallet sync failure rate > 50% for 2 consecutive runs | Check service account credentials, Google API status |
|
||||||
|
|
||||||
|
### P1 — Warn (investigate within business hours)
|
||||||
|
|
||||||
|
| Alert | Condition | Action |
|
||||||
|
|-------|-----------|--------|
|
||||||
|
| **Wallet sync failures** | `failed_card_ids` count > 5% of total cards synced | Check runbook-wallet-sync.md, inspect failed card IDs |
|
||||||
|
| **Email notification failures** | `loyalty_*` template send failure rate > 1% in 24h | Check SMTP config, EmailLog for errors |
|
||||||
|
| **Rate limit spikes** | 429 responses > 100/min per store | Investigate if legitimate traffic or abuse |
|
||||||
|
|
||||||
|
### P2 — Info (review in next sprint)
|
||||||
|
|
||||||
|
| Alert | Condition | Action |
|
||||||
|
|-------|-----------|--------|
|
||||||
|
| **High churn** | At-risk cards > 20% of active cards | Review re-engagement strategy (future marketing module) |
|
||||||
|
| **Low enrollment** | < 5 new cards in 7 days (per merchant with active program) | Check enrollment page accessibility, QR code placement |
|
||||||
|
|
||||||
|
## Key Metrics to Track
|
||||||
|
|
||||||
|
### Operational
|
||||||
|
|
||||||
|
- Celery task success/failure counts for `loyalty.expire_points` and `loyalty.sync_wallet_passes`
|
||||||
|
- EmailLog status distribution for `loyalty_*` template codes (sent/failed/bounced)
|
||||||
|
- Rate limiter 429 response count per store per hour
|
||||||
|
|
||||||
|
### Business
|
||||||
|
|
||||||
|
- Daily new enrollments (total + per merchant)
|
||||||
|
- Points issued vs redeemed ratio (health indicator: should be > 0.3 redemption rate)
|
||||||
|
- Stamp completion rate (% of cards reaching stamps_target)
|
||||||
|
- Cohort retention at month 3 (target: > 40%)
|
||||||
|
|
||||||
|
## Observability Integration
|
||||||
|
|
||||||
|
The loyalty module logs to the standard Python logger (`app.modules.loyalty.*`). Key log events:
|
||||||
|
|
||||||
|
| Logger | Level | Event |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| `card_service` | INFO | Enrollment, deactivation, GDPR anonymization |
|
||||||
|
| `stamp_service` | INFO | Stamp add/redeem/void with card and store context |
|
||||||
|
| `points_service` | INFO | Points earn/redeem/void/adjust |
|
||||||
|
| `notification_service` | INFO | Email queued (template_code + recipient) |
|
||||||
|
| `point_expiration` | INFO | Chunk processed (cards + points count) |
|
||||||
|
| `wallet_sync` | WARNING | Per-card sync failure with retry count |
|
||||||
|
| `wallet_sync` | ERROR | Card sync exhausted all retries |
|
||||||
|
|
||||||
|
## Dashboard Suggestions
|
||||||
|
|
||||||
|
If using Grafana or similar:
|
||||||
|
|
||||||
|
1. **Enrollment funnel**: Page views → Form starts → Submissions → Success (track drop-off)
|
||||||
|
2. **Transaction volume**: Stamps + Points per hour, grouped by store
|
||||||
|
3. **Wallet adoption**: % of cards with Google/Apple Wallet passes
|
||||||
|
4. **Email delivery**: Sent → Delivered → Opened → Clicked per template
|
||||||
|
5. **Task health**: Celery task execution time + success rate over 24h
|
||||||
@@ -100,7 +100,7 @@ All 8 decisions locked. No external blockers.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 2 — Notifications Infrastructure *(4d)*
|
### Phase 2A — Notifications Infrastructure *(✅ DONE 2026-04-11)*
|
||||||
|
|
||||||
#### 2.1 `LoyaltyNotificationService`
|
#### 2.1 `LoyaltyNotificationService`
|
||||||
- New `app/modules/loyalty/services/notification_service.py` with methods:
|
- New `app/modules/loyalty/services/notification_service.py` with methods:
|
||||||
@@ -144,7 +144,7 @@ All 8 decisions locked. No external blockers.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 3 — Task Reliability *(1.5d)*
|
### Phase 3 — Task Reliability *(✅ DONE 2026-04-11)*
|
||||||
|
|
||||||
#### 3.1 Batched point expiration
|
#### 3.1 Batched point expiration
|
||||||
- Rewrite `tasks/point_expiration.py:154-185` from per-card loop to set-based SQL:
|
- Rewrite `tasks/point_expiration.py:154-185` from per-card loop to set-based SQL:
|
||||||
@@ -163,7 +163,7 @@ All 8 decisions locked. No external blockers.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 4 — Accessibility & T&C *(2d)*
|
### Phase 4 — Accessibility & T&C *(✅ DONE 2026-04-11)*
|
||||||
|
|
||||||
#### 4.1 T&C via store CMS integration
|
#### 4.1 T&C via store CMS integration
|
||||||
- Migration `loyalty_007`: add `terms_cms_page_slug: str | None` to `loyalty_programs`.
|
- Migration `loyalty_007`: add `terms_cms_page_slug: str | None` to `loyalty_programs`.
|
||||||
@@ -182,7 +182,7 @@ All 8 decisions locked. No external blockers.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 5 — Google Wallet Production Hardening *(1d)*
|
### Phase 5 — Google Wallet Production Hardening *(✅ UI done 2026-04-11, deploy is manual)*
|
||||||
|
|
||||||
#### 5.1 Cert deployment
|
#### 5.1 Cert deployment
|
||||||
- Place service account JSON at `~/apps/orion/google-wallet-sa.json`, app user, mode 600.
|
- Place service account JSON at `~/apps/orion/google-wallet-sa.json`, app user, mode 600.
|
||||||
@@ -199,7 +199,7 @@ All 8 decisions locked. No external blockers.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 6 — Admin UX, GDPR, Bulk *(3d)*
|
### Phase 6 — Admin UX, GDPR, Bulk *(✅ DONE 2026-04-11)*
|
||||||
|
|
||||||
#### 6.1 Admin trash UI
|
#### 6.1 Admin trash UI
|
||||||
- Trash tab on programs list and cards list, calling existing `?only_deleted=true` API.
|
- Trash tab on programs list and cards list, calling existing `?only_deleted=true` API.
|
||||||
@@ -236,7 +236,7 @@ All 8 decisions locked. No external blockers.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 7 — Advanced Analytics *(2.5d)*
|
### Phase 7 — Advanced Analytics *(✅ DONE 2026-04-11)*
|
||||||
|
|
||||||
#### 7.1 Cohort retention
|
#### 7.1 Cohort retention
|
||||||
- New `services/analytics_service.py` (or extend `program_service`).
|
- New `services/analytics_service.py` (or extend `program_service`).
|
||||||
@@ -255,7 +255,7 @@ All 8 decisions locked. No external blockers.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 8 — Tests, Docs, Observability *(2d)*
|
### Phase 8 — Tests, Docs, Observability *(✅ DONE 2026-04-11)*
|
||||||
|
|
||||||
#### 8.1 Coverage enforcement
|
#### 8.1 Coverage enforcement
|
||||||
- Loyalty CI job: `pytest app/modules/loyalty/tests --cov=app/modules/loyalty --cov-fail-under=80`.
|
- Loyalty CI job: `pytest app/modules/loyalty/tests --cov=app/modules/loyalty --cov-fail-under=80`.
|
||||||
|
|||||||
65
app/modules/loyalty/docs/runbook-expiration-task.md
Normal file
65
app/modules/loyalty/docs/runbook-expiration-task.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Runbook: Point Expiration Task
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `loyalty.expire_points` Celery task runs daily at 02:00 (configured in `definition.py`). It processes all active programs with `points_expiration_days > 0`.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
1. **Warning emails** (14 days before expiry): finds cards whose last activity is past the warning threshold but not yet past the full expiration threshold. Sends `loyalty_points_expiring` email. Tracked via `last_expiration_warning_at` to prevent duplicates.
|
||||||
|
|
||||||
|
2. **Point expiration**: finds cards with `points_balance > 0` and `last_activity_at` older than `points_expiration_days`. Zeros the balance, creates `POINTS_EXPIRED` transaction, sends `loyalty_points_expired` email.
|
||||||
|
|
||||||
|
Processing is **chunked** (500 cards per batch with `FOR UPDATE SKIP LOCKED`) to avoid long-held row locks.
|
||||||
|
|
||||||
|
## Manual execution
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run directly (outside Celery)
|
||||||
|
python -m app.modules.loyalty.tasks.point_expiration
|
||||||
|
|
||||||
|
# Via Celery
|
||||||
|
celery -A app.core.celery_config call loyalty.expire_points
|
||||||
|
```
|
||||||
|
|
||||||
|
## Partial failure handling
|
||||||
|
|
||||||
|
- Each chunk commits independently — if the task crashes mid-run, already-processed chunks are committed
|
||||||
|
- `SKIP LOCKED` means concurrent workers won't block on the same rows
|
||||||
|
- Notification failures are caught per-card and logged but don't stop the expiration
|
||||||
|
|
||||||
|
## Re-run for a specific merchant
|
||||||
|
|
||||||
|
Not currently supported via CLI. To expire points for a single merchant:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.modules.loyalty.services.program_service import program_service
|
||||||
|
from app.modules.loyalty.tasks.point_expiration import _process_program
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
program = program_service.get_program_by_merchant(db, merchant_id=2)
|
||||||
|
cards, points, warnings = _process_program(db, program)
|
||||||
|
print(f"Expired {cards} cards, {points} points, {warnings} warnings")
|
||||||
|
db.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual point restore
|
||||||
|
|
||||||
|
If points were expired incorrectly, use the admin API:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/admin/loyalty/cards/{card_id}/restore-points
|
||||||
|
{
|
||||||
|
"points": 500,
|
||||||
|
"reason": "Incorrectly expired — customer was active"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates an `ADMIN_ADJUSTMENT` transaction and restores the balance.
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
- Alert if `loyalty.expire_points` hasn't succeeded in 26 hours
|
||||||
|
- Check Celery flower for task status and execution time
|
||||||
|
- Expected runtime: < 1 minute for < 10k cards, scales linearly with chunk count
|
||||||
51
app/modules/loyalty/docs/runbook-wallet-certs.md
Normal file
51
app/modules/loyalty/docs/runbook-wallet-certs.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Runbook: Wallet Certificate Management
|
||||||
|
|
||||||
|
## Google Wallet
|
||||||
|
|
||||||
|
### Service Account JSON
|
||||||
|
|
||||||
|
**Location (prod):** `~/apps/orion/google-wallet-sa.json` (app user, mode 600)
|
||||||
|
|
||||||
|
**Validation:** The app validates this file at startup via `config.py:google_sa_path_must_exist`. If missing or unreadable, the app fails fast with a clear error message.
|
||||||
|
|
||||||
|
### Rotation
|
||||||
|
|
||||||
|
1. Generate a new service account key in [Google Cloud Console](https://console.cloud.google.com/iam-admin/serviceaccounts)
|
||||||
|
2. Download the JSON key file
|
||||||
|
3. Replace the file at the prod path: `~/apps/orion/google-wallet-sa.json`
|
||||||
|
4. Restart the app to pick up the new key
|
||||||
|
5. Verify: check `GET /api/v1/admin/loyalty/wallet-status` returns `google_configured: true`
|
||||||
|
|
||||||
|
### Expiry Monitoring
|
||||||
|
|
||||||
|
Google service account keys don't expire by default, but Google recommends rotation every 90 days. Set a calendar reminder or monitoring alert.
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
Keep the previous key file as `google-wallet-sa.json.bak`. If the new key fails, restore the backup and restart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Apple Wallet (Phase 9 — not yet configured)
|
||||||
|
|
||||||
|
### Certificates Required
|
||||||
|
|
||||||
|
1. **Pass Type ID** — from Apple Developer portal
|
||||||
|
2. **Team ID** — your Apple Developer team identifier
|
||||||
|
3. **WWDR Certificate** — Apple Worldwide Developer Relations intermediate cert
|
||||||
|
4. **Signer Certificate** — `.pem` for your Pass Type ID
|
||||||
|
5. **Signer Key** — `.key` private key
|
||||||
|
|
||||||
|
### Planned Location
|
||||||
|
|
||||||
|
`~/apps/orion/apple-wallet/` with files: `wwdr.pem`, `signer.pem`, `signer.key`
|
||||||
|
|
||||||
|
### Apple Cert Expiry
|
||||||
|
|
||||||
|
Apple signing certificates typically expire after 1 year. The WWDR intermediate cert expires less frequently. Monitor via:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl x509 -in signer.pem -noout -enddate
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a monitoring alert for < 30 days to expiry.
|
||||||
57
app/modules/loyalty/docs/runbook-wallet-sync.md
Normal file
57
app/modules/loyalty/docs/runbook-wallet-sync.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Runbook: Wallet Sync Task
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `loyalty.sync_wallet_passes` Celery task runs hourly (configured in `definition.py`). It catches cards that missed real-time wallet updates due to transient API errors.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
1. Finds cards with transactions in the last hour that have Google or Apple Wallet integration
|
||||||
|
2. For each card, calls `wallet_service.sync_card_to_wallets(db, card)`
|
||||||
|
3. Uses **exponential backoff** (1s, 4s, 16s) with 4 total attempts per card
|
||||||
|
4. One failing card doesn't block the batch — failures are logged and reported
|
||||||
|
|
||||||
|
## Understanding `failed_card_ids`
|
||||||
|
|
||||||
|
The task returns a `failed_card_ids` list in its result. These are cards where all 4 retry attempts failed.
|
||||||
|
|
||||||
|
**Common failure causes:**
|
||||||
|
- Google Wallet API transient 500/503 errors — usually resolve on next hourly run
|
||||||
|
- Invalid service account credentials — check `wallet-status` endpoint
|
||||||
|
- Card's Google object was deleted externally — needs manual re-creation
|
||||||
|
- Network timeout — check server connectivity to `walletobjects.googleapis.com`
|
||||||
|
|
||||||
|
## Manual re-sync
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Re-run the entire sync task
|
||||||
|
celery -A app.core.celery_config call loyalty.sync_wallet_passes
|
||||||
|
|
||||||
|
# Re-sync a specific card (Python shell)
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.modules.loyalty.services import wallet_service
|
||||||
|
from app.modules.loyalty.models import LoyaltyCard
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
card = db.query(LoyaltyCard).get(card_id)
|
||||||
|
result = wallet_service.sync_card_to_wallets(db, card)
|
||||||
|
print(result)
|
||||||
|
db.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
- Alert if `loyalty.sync_wallet_passes` failure rate > 5% (more than 5% of cards fail after all retries)
|
||||||
|
- Check Celery flower for task execution time — should be < 30s for typical loads
|
||||||
|
- Large `failed_card_ids` lists (> 10) may indicate a systemic API issue
|
||||||
|
|
||||||
|
## Retry behavior
|
||||||
|
|
||||||
|
| Attempt | Delay before | Total elapsed |
|
||||||
|
|---------|-------------|---------------|
|
||||||
|
| 1 | 0s | 0s |
|
||||||
|
| 2 | 1s | 1s |
|
||||||
|
| 3 | 4s | 5s |
|
||||||
|
| 4 | 16s | 21s |
|
||||||
|
|
||||||
|
After attempt 4 fails, the card is added to `failed_card_ids` and will be retried on the next hourly run.
|
||||||
@@ -39,6 +39,7 @@ from app.modules.loyalty.schemas import (
|
|||||||
TransactionResponse,
|
TransactionResponse,
|
||||||
)
|
)
|
||||||
from app.modules.loyalty.services import card_service, pin_service, program_service
|
from app.modules.loyalty.services import card_service, pin_service, program_service
|
||||||
|
from app.modules.loyalty.services.analytics_service import analytics_service
|
||||||
from app.modules.tenancy.models import User # API-007
|
from app.modules.tenancy.models import User # API-007
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -46,6 +47,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# Admin router with module access control
|
# Admin router with module access control
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/loyalty",
|
prefix="/loyalty",
|
||||||
|
tags=["Loyalty - Admin"],
|
||||||
dependencies=[Depends(require_module_access("loyalty", FrontendType.ADMIN))],
|
dependencies=[Depends(require_module_access("loyalty", FrontendType.ADMIN))],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -495,6 +497,50 @@ def get_platform_stats(
|
|||||||
return program_service.get_platform_stats(db)
|
return program_service.get_platform_stats(db)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Advanced Analytics
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/merchants/{merchant_id}/analytics/cohorts")
|
||||||
|
def get_cohort_retention(
|
||||||
|
merchant_id: int = Path(..., gt=0),
|
||||||
|
months_back: int = Query(6, ge=1, le=24),
|
||||||
|
current_user: User = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Cohort retention matrix for a merchant's loyalty program."""
|
||||||
|
return analytics_service.get_cohort_retention(
|
||||||
|
db, merchant_id, months_back=months_back
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/merchants/{merchant_id}/analytics/churn")
|
||||||
|
def get_at_risk_cards(
|
||||||
|
merchant_id: int = Path(..., gt=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
current_user: User = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Cards at risk of churn for a merchant."""
|
||||||
|
return analytics_service.get_at_risk_cards(
|
||||||
|
db, merchant_id, limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/merchants/{merchant_id}/analytics/revenue")
|
||||||
|
def get_revenue_attribution(
|
||||||
|
merchant_id: int = Path(..., gt=0),
|
||||||
|
months_back: int = Query(6, ge=1, le=24),
|
||||||
|
current_user: User = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Revenue attribution from loyalty transactions."""
|
||||||
|
return analytics_service.get_revenue_attribution(
|
||||||
|
db, merchant_id, months_back=months_back
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Wallet Integration Status
|
# Wallet Integration Status
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# Store router with module access control
|
# Store router with module access control
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/loyalty",
|
prefix="/loyalty",
|
||||||
|
tags=["Loyalty - Store"],
|
||||||
dependencies=[Depends(require_module_access("loyalty", FrontendType.STORE))],
|
dependencies=[Depends(require_module_access("loyalty", FrontendType.STORE))],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -198,6 +199,51 @@ def get_merchant_stats(
|
|||||||
return MerchantStatsResponse(**stats)
|
return MerchantStatsResponse(**stats)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/analytics/cohorts")
|
||||||
|
def get_cohort_retention(
|
||||||
|
months_back: int = Query(6, ge=1, le=24),
|
||||||
|
current_user: User = Depends(get_current_store_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Cohort retention matrix for this merchant's loyalty program."""
|
||||||
|
from app.modules.loyalty.services.analytics_service import analytics_service
|
||||||
|
|
||||||
|
merchant_id = get_store_merchant_id(db, current_user.token_store_id)
|
||||||
|
return analytics_service.get_cohort_retention(
|
||||||
|
db, merchant_id, months_back=months_back
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/analytics/churn")
|
||||||
|
def get_at_risk_cards(
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
current_user: User = Depends(get_current_store_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Cards at risk of churn for this merchant."""
|
||||||
|
from app.modules.loyalty.services.analytics_service import analytics_service
|
||||||
|
|
||||||
|
merchant_id = get_store_merchant_id(db, current_user.token_store_id)
|
||||||
|
return analytics_service.get_at_risk_cards(
|
||||||
|
db, merchant_id, limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/analytics/revenue")
|
||||||
|
def get_revenue_attribution(
|
||||||
|
months_back: int = Query(6, ge=1, le=24),
|
||||||
|
current_user: User = Depends(get_current_store_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Revenue attribution from loyalty transactions."""
|
||||||
|
from app.modules.loyalty.services.analytics_service import analytics_service
|
||||||
|
|
||||||
|
merchant_id = get_store_merchant_id(db, current_user.token_store_id)
|
||||||
|
return analytics_service.get_revenue_attribution(
|
||||||
|
db, merchant_id, months_back=months_back
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Staff PINs
|
# Staff PINs
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
338
app/modules/loyalty/services/analytics_service.py
Normal file
338
app/modules/loyalty/services/analytics_service.py
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
# app/modules/loyalty/services/analytics_service.py
|
||||||
|
"""
|
||||||
|
Loyalty analytics service.
|
||||||
|
|
||||||
|
Advanced analytics beyond basic stats:
|
||||||
|
- Cohort retention (enrollment month → % active per subsequent month)
|
||||||
|
- Churn detection (at-risk cards based on inactivity)
|
||||||
|
- Revenue attribution (loyalty vs non-loyalty per store)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.modules.loyalty.models import LoyaltyCard, LoyaltyTransaction
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsService:
|
||||||
|
"""Advanced loyalty analytics."""
|
||||||
|
|
||||||
|
def get_cohort_retention(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
merchant_id: int,
|
||||||
|
months_back: int = 6,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Cohort retention matrix.
|
||||||
|
|
||||||
|
Groups cards by enrollment month and tracks what % had any
|
||||||
|
transaction in each subsequent month.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"cohorts": [
|
||||||
|
{
|
||||||
|
"month": "2026-01",
|
||||||
|
"enrolled": 50,
|
||||||
|
"retention": [100, 80, 65, 55, ...] # % active per month
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
start_date = now - timedelta(days=months_back * 31)
|
||||||
|
|
||||||
|
# Get enrollment month for each card
|
||||||
|
cards = (
|
||||||
|
db.query(
|
||||||
|
LoyaltyCard.id,
|
||||||
|
func.date_trunc("month", LoyaltyCard.created_at).label(
|
||||||
|
"enrollment_month"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
LoyaltyCard.merchant_id == merchant_id,
|
||||||
|
LoyaltyCard.created_at >= start_date,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not cards:
|
||||||
|
return {"cohorts": [], "months_back": months_back}
|
||||||
|
|
||||||
|
# Group cards by enrollment month
|
||||||
|
cohort_cards: dict[str, list[int]] = {}
|
||||||
|
for card_id, enrollment_month in cards:
|
||||||
|
month_key = enrollment_month.strftime("%Y-%m")
|
||||||
|
cohort_cards.setdefault(month_key, []).append(card_id)
|
||||||
|
|
||||||
|
# For each cohort, check activity in subsequent months
|
||||||
|
cohorts = []
|
||||||
|
for month_key in sorted(cohort_cards.keys()):
|
||||||
|
card_ids = cohort_cards[month_key]
|
||||||
|
enrolled_count = len(card_ids)
|
||||||
|
|
||||||
|
# Calculate months since enrollment
|
||||||
|
cohort_start = datetime.strptime(month_key, "%Y-%m").replace(
|
||||||
|
tzinfo=UTC
|
||||||
|
)
|
||||||
|
months_since = max(
|
||||||
|
1,
|
||||||
|
(now.year - cohort_start.year) * 12
|
||||||
|
+ (now.month - cohort_start.month),
|
||||||
|
)
|
||||||
|
|
||||||
|
retention = []
|
||||||
|
for month_offset in range(min(months_since, months_back)):
|
||||||
|
period_start = cohort_start + timedelta(days=month_offset * 30)
|
||||||
|
period_end = period_start + timedelta(days=30)
|
||||||
|
|
||||||
|
# Count cards with any transaction in this period
|
||||||
|
active_count = (
|
||||||
|
db.query(func.count(func.distinct(LoyaltyTransaction.card_id)))
|
||||||
|
.filter(
|
||||||
|
LoyaltyTransaction.card_id.in_(card_ids),
|
||||||
|
LoyaltyTransaction.transaction_at >= period_start,
|
||||||
|
LoyaltyTransaction.transaction_at < period_end,
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
pct = round(active_count / enrolled_count * 100) if enrolled_count else 0
|
||||||
|
retention.append(pct)
|
||||||
|
|
||||||
|
cohorts.append(
|
||||||
|
{
|
||||||
|
"month": month_key,
|
||||||
|
"enrolled": enrolled_count,
|
||||||
|
"retention": retention,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"cohorts": cohorts, "months_back": months_back}
|
||||||
|
|
||||||
|
def get_at_risk_cards(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
merchant_id: int,
|
||||||
|
inactivity_multiplier: float = 2.0,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Simple churn detection.
|
||||||
|
|
||||||
|
A card is "at risk" when its inactivity period exceeds
|
||||||
|
`inactivity_multiplier` × its average inter-transaction interval.
|
||||||
|
Falls back to 60 days for cards with fewer than 2 transactions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"at_risk_count": int,
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"card_id": int,
|
||||||
|
"card_number": str,
|
||||||
|
"customer_name": str,
|
||||||
|
"days_inactive": int,
|
||||||
|
"avg_interval_days": int,
|
||||||
|
"points_balance": int,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
default_threshold_days = 60
|
||||||
|
|
||||||
|
# Get active cards with their last activity
|
||||||
|
cards = (
|
||||||
|
db.query(LoyaltyCard)
|
||||||
|
.filter(
|
||||||
|
LoyaltyCard.merchant_id == merchant_id,
|
||||||
|
LoyaltyCard.is_active == True, # noqa: E712
|
||||||
|
LoyaltyCard.last_activity_at.isnot(None),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
at_risk = []
|
||||||
|
for card in cards:
|
||||||
|
days_inactive = (now - card.last_activity_at).days
|
||||||
|
|
||||||
|
# Calculate average interval from transaction history
|
||||||
|
tx_dates = (
|
||||||
|
db.query(LoyaltyTransaction.transaction_at)
|
||||||
|
.filter(LoyaltyTransaction.card_id == card.id)
|
||||||
|
.order_by(LoyaltyTransaction.transaction_at)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(tx_dates) >= 2:
|
||||||
|
intervals = [
|
||||||
|
(tx_dates[i + 1][0] - tx_dates[i][0]).days
|
||||||
|
for i in range(len(tx_dates) - 1)
|
||||||
|
]
|
||||||
|
avg_interval = sum(intervals) / len(intervals) if intervals else default_threshold_days
|
||||||
|
else:
|
||||||
|
avg_interval = default_threshold_days
|
||||||
|
|
||||||
|
threshold = avg_interval * inactivity_multiplier
|
||||||
|
|
||||||
|
if days_inactive > threshold:
|
||||||
|
customer_name = None
|
||||||
|
if card.customer:
|
||||||
|
customer_name = card.customer.full_name
|
||||||
|
|
||||||
|
at_risk.append(
|
||||||
|
{
|
||||||
|
"card_id": card.id,
|
||||||
|
"card_number": card.card_number,
|
||||||
|
"customer_name": customer_name,
|
||||||
|
"days_inactive": days_inactive,
|
||||||
|
"avg_interval_days": round(avg_interval),
|
||||||
|
"points_balance": card.points_balance,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort by days_inactive descending
|
||||||
|
at_risk.sort(key=lambda x: x["days_inactive"], reverse=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"at_risk_count": len(at_risk),
|
||||||
|
"cards": at_risk[:limit],
|
||||||
|
"total_cards_checked": len(cards),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_revenue_attribution(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
merchant_id: int,
|
||||||
|
months_back: int = 6,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Revenue attribution from loyalty point-earning transactions.
|
||||||
|
|
||||||
|
Compares revenue from transactions with order references
|
||||||
|
(loyalty customers) against total enrollment metrics.
|
||||||
|
Groups by month and store.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"monthly": [
|
||||||
|
{
|
||||||
|
"month": "2026-01",
|
||||||
|
"transactions_count": int,
|
||||||
|
"total_points_earned": int,
|
||||||
|
"estimated_revenue_cents": int,
|
||||||
|
"unique_customers": int,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"by_store": [
|
||||||
|
{
|
||||||
|
"store_id": int,
|
||||||
|
"store_name": str,
|
||||||
|
"transactions_count": int,
|
||||||
|
"total_points_earned": int,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
from app.modules.loyalty.models.loyalty_transaction import TransactionType
|
||||||
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
start_date = now - timedelta(days=months_back * 31)
|
||||||
|
|
||||||
|
# Monthly aggregation of point-earning transactions
|
||||||
|
monthly_rows = (
|
||||||
|
db.query(
|
||||||
|
func.date_trunc("month", LoyaltyTransaction.transaction_at).label(
|
||||||
|
"month"
|
||||||
|
),
|
||||||
|
func.count(LoyaltyTransaction.id).label("tx_count"),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(LoyaltyTransaction.points_delta), 0
|
||||||
|
).label("points_earned"),
|
||||||
|
func.count(
|
||||||
|
func.distinct(LoyaltyTransaction.card_id)
|
||||||
|
).label("unique_cards"),
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
LoyaltyTransaction.merchant_id == merchant_id,
|
||||||
|
LoyaltyTransaction.transaction_at >= start_date,
|
||||||
|
LoyaltyTransaction.transaction_type.in_(
|
||||||
|
[
|
||||||
|
TransactionType.POINTS_EARNED.value,
|
||||||
|
TransactionType.STAMP_EARNED.value,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
LoyaltyTransaction.points_delta > 0,
|
||||||
|
)
|
||||||
|
.group_by("month")
|
||||||
|
.order_by("month")
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
monthly = []
|
||||||
|
for row in monthly_rows:
|
||||||
|
monthly.append(
|
||||||
|
{
|
||||||
|
"month": row.month.strftime("%Y-%m"),
|
||||||
|
"transactions_count": row.tx_count,
|
||||||
|
"total_points_earned": row.points_earned,
|
||||||
|
"unique_customers": row.unique_cards,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-store breakdown
|
||||||
|
store_rows = (
|
||||||
|
db.query(
|
||||||
|
LoyaltyTransaction.store_id,
|
||||||
|
func.count(LoyaltyTransaction.id).label("tx_count"),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(LoyaltyTransaction.points_delta), 0
|
||||||
|
).label("points_earned"),
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
LoyaltyTransaction.merchant_id == merchant_id,
|
||||||
|
LoyaltyTransaction.transaction_at >= start_date,
|
||||||
|
LoyaltyTransaction.transaction_type.in_(
|
||||||
|
[
|
||||||
|
TransactionType.POINTS_EARNED.value,
|
||||||
|
TransactionType.STAMP_EARNED.value,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
LoyaltyTransaction.points_delta > 0,
|
||||||
|
LoyaltyTransaction.store_id.isnot(None),
|
||||||
|
)
|
||||||
|
.group_by(LoyaltyTransaction.store_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
by_store = []
|
||||||
|
for row in store_rows:
|
||||||
|
store = store_service.get_store_by_id_optional(db, row.store_id)
|
||||||
|
by_store.append(
|
||||||
|
{
|
||||||
|
"store_id": row.store_id,
|
||||||
|
"store_name": store.name if store else f"Store {row.store_id}",
|
||||||
|
"transactions_count": row.tx_count,
|
||||||
|
"total_points_earned": row.points_earned,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"monthly": monthly,
|
||||||
|
"by_store": by_store,
|
||||||
|
"months_back": months_back,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton
|
||||||
|
analytics_service = AnalyticsService()
|
||||||
@@ -220,6 +220,10 @@ nav:
|
|||||||
- Program Analysis: modules/loyalty/program-analysis.md
|
- Program Analysis: modules/loyalty/program-analysis.md
|
||||||
- UI Design: modules/loyalty/ui-design.md
|
- UI Design: modules/loyalty/ui-design.md
|
||||||
- Production Launch Plan: modules/loyalty/production-launch-plan.md
|
- Production Launch Plan: modules/loyalty/production-launch-plan.md
|
||||||
|
- Monitoring: modules/loyalty/monitoring.md
|
||||||
|
- Runbook - Wallet Certs: modules/loyalty/runbook-wallet-certs.md
|
||||||
|
- Runbook - Expiration Task: modules/loyalty/runbook-expiration-task.md
|
||||||
|
- Runbook - Wallet Sync: modules/loyalty/runbook-wallet-sync.md
|
||||||
- Marketplace:
|
- Marketplace:
|
||||||
- Overview: modules/marketplace/index.md
|
- Overview: modules/marketplace/index.md
|
||||||
- Data Model: modules/marketplace/data-model.md
|
- Data Model: modules/marketplace/data-model.md
|
||||||
|
|||||||
Reference in New Issue
Block a user