Client requirement: sellers must select a product category (e.g., Men, Women, Accessories, Kids) when entering loyalty transactions. Categories are per-store, configured via admin/merchant CRUD. Proposal covers: data model (StoreTransactionCategory + FK on transactions), CRUD API for admin + store, web terminal UI, Android terminal integration, and analytics extension path. Priority: urgent for production launch. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6.7 KiB
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:
- Create
store_transaction_categoriestable - Add
category_idFK column toloyalty_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
class CategoryCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
display_order: int = Field(default=0, ge=0)
CategoryUpdate
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
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_ordercreate_category(db, store_id, data)— enforce max 10 per storeupdate_category(db, category_id, store_id, data)— ownership checkdelete_category(db, category_id, store_id)— ownership checkvalidate_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)