diff --git a/docs/proposals/transaction-categories.md b/docs/proposals/transaction-categories.md new file mode 100644 index 00000000..fc5747fc --- /dev/null +++ b/docs/proposals/transaction-categories.md @@ -0,0 +1,174 @@ +# Proposal: Transaction Categories (What Was Sold) + +**Created:** 2026-04-19 +**Status:** Approved — ready to implement +**Priority:** Urgent (client requirement for production launch) + +## Context + +The client requires sellers to select what was sold (e.g., Men, Women, Accessories, Kids) when entering a loyalty transaction. This enables per-category sales analytics for merchants. Each store can have 4-5 categories, configured by the admin or merchant owner. + +## Data Model + +### New table: `store_transaction_categories` + +| Column | Type | Description | +|---|---|---| +| `id` | Integer PK | Auto-increment | +| `store_id` | FK → stores | Store this category belongs to | +| `name` | String(100) | Display name (e.g., "Men", "Women", "Accessories") | +| `display_order` | Integer | Sort order in the selector (1, 2, 3...) | +| `is_active` | Boolean | Soft-disable without deleting | +| `created_at` | DateTime | Timestamp | +| `updated_at` | DateTime | Timestamp | + +**Unique constraint:** `(store_id, name)` — no duplicate names per store. +**Max categories per store:** 10 (enforced at API level, soft limit). + +### Modify: `loyalty_transactions` + +| Column | Type | Description | +|---|---|---| +| `category_id` | FK → store_transaction_categories (nullable) | What was sold | + +Nullable because: existing transactions don't have categories, and enrollment/expiration/void transactions don't involve a sale. + +## Migration + +`loyalty_007_add_transaction_categories.py`: +1. Create `store_transaction_categories` table +2. Add `category_id` FK column to `loyalty_transactions` + +## API Endpoints + +### Admin CRUD (on behalf of store) + +| Method | Endpoint | Description | +|---|---|---| +| `GET` | `/admin/loyalty/stores/{store_id}/categories` | List categories for a store | +| `POST` | `/admin/loyalty/stores/{store_id}/categories` | Create category | +| `PATCH` | `/admin/loyalty/stores/{store_id}/categories/{id}` | Update category (name, order, active) | +| `DELETE` | `/admin/loyalty/stores/{store_id}/categories/{id}` | Delete category | + +### Store/Merchant CRUD (own store) + +| Method | Endpoint | Description | +|---|---|---| +| `GET` | `/store/loyalty/categories` | List categories for current store | +| `POST` | `/store/loyalty/categories` | Create category | +| `PATCH` | `/store/loyalty/categories/{id}` | Update category | +| `DELETE` | `/store/loyalty/categories/{id}` | Delete category | + +### Modified transaction endpoints + +`POST /store/loyalty/stamp`, `POST /store/loyalty/points/earn`, `POST /store/loyalty/points/redeem` — add optional `category_id` field to request body. + +`GET /store/loyalty/transactions` and card detail transactions — include `category_name` in response. + +## Schemas + +### CategoryCreate +```python +class CategoryCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + display_order: int = Field(default=0, ge=0) +``` + +### CategoryUpdate +```python +class CategoryUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=100) + display_order: int | None = Field(None, ge=0) + is_active: bool | None = None +``` + +### CategoryResponse +```python +class CategoryResponse(BaseModel): + id: int + store_id: int + name: str + display_order: int + is_active: bool +``` + +## Service Layer + +New `category_service.py` with: +- `list_categories(db, store_id)` — ordered by display_order +- `create_category(db, store_id, data)` — enforce max 10 per store +- `update_category(db, category_id, store_id, data)` — ownership check +- `delete_category(db, category_id, store_id)` — ownership check +- `validate_category_for_store(db, category_id, store_id)` — used by stamp/points services + +## UI Changes + +### Admin panel +- New "Transaction Categories" section on the merchant store detail page +- Table with name, order, active toggle, edit/delete buttons +- "Add Category" button with modal form + +### Store/Merchant settings +- New "Transaction Categories" tab or section on the store settings page +- Same CRUD UI as admin + +### Web terminal +- Category selector (radio buttons or button group) shown before the PIN modal +- Only shown when the store has categories configured +- Stored on the transaction + +### Android terminal +- Same category selector in the transaction flow +- Categories cached locally (refreshed from API periodically) +- Works offline (category list is static, just an ID reference) + +## Card Lookup Response + +Add `categories: list[CategoryResponse]` to `CardLookupResponse` so the terminal has the list without a separate API call. + +Or: fetch categories once on terminal load and cache them. + +**Recommendation:** Fetch once on terminal load — categories are store-level, not card-level. Add `GET /store/loyalty/categories` call to terminal init. + +## Transaction History + +`TransactionResponse` gets a new `category_name: str | None` field so card detail and terminal transaction tables can show what was sold. + +## Analytics + +The `analytics_service` can later be extended to group revenue by category — but that's a future enhancement, not in this scope. + +## Files to create/modify + +| File | Action | +|---|---| +| `app/modules/loyalty/models/transaction_category.py` | NEW — model | +| `app/modules/loyalty/models/loyalty_transaction.py` | Add `category_id` FK | +| `app/modules/loyalty/migrations/versions/loyalty_007_*.py` | NEW — migration | +| `app/modules/loyalty/services/category_service.py` | NEW — CRUD service | +| `app/modules/loyalty/schemas/category.py` | NEW — Pydantic schemas | +| `app/modules/loyalty/schemas/card.py` | Add `category_id` to stamp/points requests | +| `app/modules/loyalty/routes/api/admin.py` | Add admin CRUD endpoints | +| `app/modules/loyalty/routes/api/store.py` | Add store CRUD + modify transaction endpoints | +| `app/modules/loyalty/services/stamp_service.py` | Accept + store `category_id` | +| `app/modules/loyalty/services/points_service.py` | Accept + store `category_id` | +| `app/modules/loyalty/templates/loyalty/store/terminal.html` | Add category selector | +| `app/modules/loyalty/static/store/js/loyalty-terminal.js` | Load categories, pass to transactions | +| `clients/terminal-android/.../data/model/ApiModels.kt` | Add `category_id` to request models | + +## Effort + +~0.5 day backend (model + migration + service + routes + schemas) +~0.5 day frontend (web terminal category selector + admin CRUD UI) +Android terminal gets it for free via the API models already scaffolded. + +## Test plan + +- Create 5 categories for a store via admin API +- Verify max 10 limit +- Add stamp with category_id → transaction has category +- Add points with category_id → transaction has category +- Transaction history shows category name +- Terminal loads categories on init +- Category selector appears only when categories exist +- Delete category → existing transactions keep the name (FK nullable, name denormalized or joined)