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`
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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,
|
||||
)
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -46,6 +47,7 @@ logger = logging.getLogger(__name__)
|
||||
# Admin router with module access control
|
||||
router = APIRouter(
|
||||
prefix="/loyalty",
|
||||
tags=["Loyalty - Admin"],
|
||||
dependencies=[Depends(require_module_access("loyalty", FrontendType.ADMIN))],
|
||||
)
|
||||
|
||||
@@ -495,6 +497,50 @@ def get_platform_stats(
|
||||
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
|
||||
# =============================================================================
|
||||
|
||||
@@ -69,6 +69,7 @@ logger = logging.getLogger(__name__)
|
||||
# Store router with module access control
|
||||
router = APIRouter(
|
||||
prefix="/loyalty",
|
||||
tags=["Loyalty - Store"],
|
||||
dependencies=[Depends(require_module_access("loyalty", FrontendType.STORE))],
|
||||
)
|
||||
|
||||
@@ -198,6 +199,51 @@ def get_merchant_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
|
||||
# =============================================================================
|
||||
|
||||
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
|
||||
- UI Design: modules/loyalty/ui-design.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:
|
||||
- Overview: modules/marketplace/index.md
|
||||
- Data Model: modules/marketplace/data-model.md
|
||||
|
||||
Reference in New Issue
Block a user