# 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
