Implement stores and refills, improve testing

This commit is contained in:
Aerilyn Weber 2026-04-18 12:36:29 +09:00
parent 9f416903ef
commit 5536acd67d
137 changed files with 21218 additions and 221 deletions

View file

@ -1,19 +1,30 @@
# Phase 4 — Pharmacies, Prices & Refills
# Phase 4 — Pharmacies, Prices, Purchases & Refills
**Goal**: Track where you buy medicines, compare prices across pharmacies, and get automatic refill alerts when cabinet stock is running low. The Store and PriceRecord infrastructure built here is shared with food tracking (Phase 9).
**Goal**: Track store catalog prices for medicines, record purchases (which may mix medicines and food), and get automatic refill alerts when cabinet stock is running low. The Store and PriceRecord infrastructure built here is shared with food tracking (Phase 9).
**Depends on**: Phase 0, Phase 1 (medicines), Phase 2 (cabinet), Phase 3 (regimens — for burn rate)
---
## Deliverables
## Domain Model
1. `Store` MongoDB schema and CRUD API (shared infrastructure)
2. `PriceRecord` schema for medicine price tracking
3. Price history and store comparison
4. Refill alerts based on burn rate
5. Refill list generation
6. Web UI: stores, price history, refill management
### Price Records vs Purchases
These are two distinct concepts:
- **Price Record** — a catalog observation: "Product X at Store Y is listed at $Z for a pack of N units." Nothing moves. Multiple records can exist for the same product+store (different pack sizes, bulk deals, insurance tier, etc.). This is price surveillance.
- **Purchase** — recording that you actually acquired something. Can contain multiple line items mixing medicines and (Phase 9) food products from the same store.
### Purchase Status
Most purchases happen at a physical store and items are immediately in hand. Online purchases may take time to arrive. The status reflects this:
- **`in_cabinet`** — the default for physical store purchases. Items are available immediately and are added to the cabinet right away.
- **`ordered`** — used only for online purchases where the items have not yet arrived. Items in this state are counted toward pending stock in refill alerts (so the user is not repeatedly alerted to reorder something already purchased and on its way). Once items arrive, the purchase is moved to `in_cabinet` and items are added to the cabinet.
For food (Phase 9), purchases have no intermediate state — food items always go directly to pantry/fridge upon recording.
Refill alerts must account for both cabinet stock and purchases with status `ordered` so the alert reflects actual available stock.
---
@ -28,13 +39,10 @@ export interface Store {
householdId: string;
name: string;
address?: string;
location?: {
lat: number;
lng: number;
};
location?: { lat: number; lng: number };
url?: string;
notes?: string;
tags: string[]; // e.g., 'pharmacy', 'grocery', 'online', 'bulk', 'discount'
tags: string[]; // e.g. 'pharmacy', 'grocery', 'online', 'bulk', 'discount'
isActive: boolean;
createdBy: string;
createdAt: Date;
@ -49,25 +57,62 @@ export interface Store {
export interface MedicinePriceRecord {
id: string;
householdId: string;
medicineProductId: string; // Reference to MedicineProduct (purchasable level)
medicineProductBrand: string; // Denormalized
medicineId: string; // Reference to Medicine (generic level, for cross-brand comparison)
medicineName: string; // Denormalized
medicineProductId: string; // specific brand/package
medicineProductBrand: string; // denormalized
medicineId: string; // generic medicine, for cross-brand comparison
medicineName: string; // denormalized
storeId: string;
storeName: string; // Denormalized
storeName: string; // denormalized
price: number;
currency: string; // Default from household settings
quantity: number; // How many pills/units for this price (package size)
currency: string;
quantity: number; // units in this price point (package size)
unit: DosageUnit;
pricePerUnit: number; // Computed: price / quantity
date: Date;
isInsurancePrice: boolean; // With insurance vs retail
notes?: string;
pricePerUnit: number; // computed: price / quantity
isInsurancePrice: boolean;
notes?: string; // e.g. "bulk deal", "fast shipping tier"
date: Date; // when price was observed
createdBy: string;
createdAt: Date;
}
```
### Purchase Schema
```typescript
// packages/shared/src/types/purchase.ts
export type PurchaseStatus = 'ordered' | 'in_cabinet';
export interface Purchase {
id: string;
householdId: string;
storeId: string;
storeName: string; // denormalized
status: PurchaseStatus; // 'in_cabinet' for physical; 'ordered' for online until received
items: PurchaseItem[];
notes?: string;
purchasedAt: Date; // when the purchase was made (or order placed)
receivedAt?: Date; // set when status moves to in_cabinet for online orders
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export interface PurchaseItem {
id: string; // stable item id within the purchase
// exactly one of these is set:
medicineProductId?: string;
foodProductId?: string; // reserved for Phase 9
// denormalized display info:
name: string; // brand name / food product name
quantity: number; // units in package
unit: string;
actualPrice?: number; // what was actually paid (optional)
currency?: string;
priceRecordId?: string; // optionally links to a PriceRecord used as reference
addedToCabinet: boolean;
}
```
### RefillAlert (Computed, not stored)
```typescript
@ -77,18 +122,20 @@ export interface RefillAlert {
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: StrengthUnit;
daysUntilEmpty: number;
daysUntilEmpty: number; // based on cabinet stock only
daysUntilEmptyWithOrders: number; // cabinet + pending ordered items
dailyConsumption: number;
currentStock: number;
suggestedQuantity: number; // Enough for N days (configurable, default 30)
lastKnownPrice?: {
pendingOrderStock: number; // units in orders with status 'ordered'
suggestedQuantity: number; // enough for N days (configurable, default 30)
cheapestOption?: {
price: number;
pricePerUnit: number;
storeName: string;
storeId: string;
date: Date;
};
cheapestOption?: {
lastKnownPrice?: {
price: number;
pricePerUnit: number;
storeName: string;
@ -142,9 +189,14 @@ export enum RefillListStatus {
// MedicinePriceRecord
{ householdId: 1, medicineProductId: 1, storeId: 1, date: -1 }
{ householdId: 1, medicineId: 1, date: -1 } // Cross-brand comparison
{ householdId: 1, medicineId: 1, date: -1 }
{ householdId: 1, storeId: 1, date: -1 }
// Purchase
{ householdId: 1, status: 1, purchasedAt: -1 }
{ householdId: 1, storeId: 1 }
{ householdId: 1, 'items.medicineProductId': 1 }
// RefillList
{ householdId: 1, status: 1 }
{ householdId: 1, createdAt: -1 }
@ -166,42 +218,55 @@ export enum RefillListStatus {
### MedicinePricesModule
| Method | Path | Description | Auth |
| ------ | -------------------------------------- | --------------------------------- | ------ |
| POST | `/medicine-prices` | Record a price | member |
| GET | `/medicine-prices/history/:medicineId` | Price history for a medicine | member |
| GET | `/medicine-prices/compare/:medicineId` | Compare stores for a medicine | member |
| GET | `/medicine-prices/analytics` | Spending analytics | member |
| Method | Path | Description | Auth |
| ------ | -------------------------------------- | --------------------------------------- | ------ |
| POST | `/medicine-prices` | Record a catalog price observation | member |
| GET | `/medicine-prices/history/:medicineId` | Price history for a medicine | member |
| GET | `/medicine-prices/compare/:medicineId` | Compare stores for a medicine | member |
| GET | `/medicine-prices/analytics` | Price trend analytics | member |
### PurchasesModule
| Method | Path | Description | Auth |
| ------ | ------------------------------- | --------------------------------------------------------- | ------ |
| POST | `/purchases` | Record a purchase (physical → immediately in_cabinet; online → ordered) | member |
| GET | `/purchases` | List purchases (filterable by status) | member |
| GET | `/purchases/:id` | Get purchase detail | member |
| PATCH | `/purchases/:id` | Update purchase (notes, items) | member |
| POST | `/purchases/:id/receive` | Mark online purchase received — moves items to cabinet | member |
| DELETE | `/purchases/:id` | Delete purchase (only if status is ordered) | member |
### RefillsModule
| Method | Path | Description | Auth |
| ------ | ----------------------------------- | ---------------------------------------- | ------ |
| GET | `/refills/alerts` | Get refill alerts (medicines running low)| member |
| POST | `/refills/lists` | Create refill list (manual or from alerts)| member |
| GET | `/refills/lists` | List refill lists | member |
| GET | `/refills/lists/:id` | Get refill list | member |
| PATCH | `/refills/lists/:id` | Update refill list | member |
| PATCH | `/refills/lists/:id/items/:itemId` | Check off / update item | member |
| POST | `/refills/lists/:id/add-to-cabinet` | Move checked items to cabinet | member |
| GET | `/refills/lists/:id/store-comparison`| Best store for this list | member |
| Method | Path | Description | Auth |
| ------ | ---------------------------------- | ----------------------------------------- | ------ |
| GET | `/refills/alerts` | Get refill alerts (accounts for pending orders) | member |
| POST | `/refills/lists` | Create refill list (manual or from alerts)| member |
| GET | `/refills/lists` | List refill lists | member |
| GET | `/refills/lists/:id` | Get refill list | member |
| PATCH | `/refills/lists/:id` | Update refill list | member |
| PATCH | `/refills/lists/:id/items/:itemId` | Check off / update item | member |
### Query Parameters
```
# GET /stores
?tags=pharmacy # Filter by tags
&search=walgreens # Name search
?tags=pharmacy
&search=walgreens
# GET /medicine-prices/history/:medicineId
?storeId=abc123 # Filter by store
&startDate=2026-01-01 # Date range
?storeId=abc123
&startDate=2026-01-01
&endDate=2026-03-27
&limit=50
# GET /purchases
?status=ordered
&storeId=abc123
# GET /refills/alerts
?thresholdDays=7 # Alert when <= N days of stock remain (default: 7)
&userId=abc123 # Filter by user's regimens
?thresholdDays=7
&userId=abc123
```
---
@ -210,9 +275,10 @@ export enum RefillListStatus {
### 4.1 — Shared Types & Validation
- Add store types to `packages/shared/src/types/store.ts`
- Add medicine price types to `packages/shared/src/types/medicine-price.ts`
- Add refill types to `packages/shared/src/types/refill.ts`
- Store types in `packages/shared/src/types/store.ts`
- Medicine price types in `packages/shared/src/types/medicine-price.ts`
- Order types in `packages/shared/src/types/order.ts`
- Refill types in `packages/shared/src/types/refill.ts`
- Zod schemas for all create/update operations
### 4.2 — Stores CRUD (Shared Infrastructure)
@ -220,89 +286,85 @@ export enum RefillListStatus {
- `packages/api/src/modules/stores/`
- Standard CRUD, scoped to `householdId`
- Tag-based filtering (pharmacy, grocery, online, etc.)
- This module is used by both medicine and food domains
- Reused by both medicine and food domains
### 4.3 — Medicine Price Service
Records catalog price observations. Multiple records per product+store are allowed (different pack sizes, bulk tiers, insurance pricing).
```typescript
class MedicinePriceService {
/** Record a price, computing pricePerUnit */
recordPrice(data: CreateMedicinePriceRecord): Promise<MedicinePriceRecord>;
/** Get price history for a medicine, optionally filtered by store */
getPriceHistory(medicineId: string, householdId: string, options?: {
storeId?: string;
startDate?: Date;
endDate?: Date;
limit?: number;
}): Promise<MedicinePriceRecord[]>;
/** Compare current prices across stores for a medicine */
recordPrice(data: CreateMedicinePriceRecord, householdId: string): Promise<MedicinePriceRecord>;
getPriceHistory(medicineId: string, householdId: string, options?: { storeId?: string; startDate?: Date; endDate?: Date; limit?: number }): Promise<MedicinePriceRecord[]>;
compareStores(medicineId: string, householdId: string): Promise<StoreComparison[]>;
/** Estimate price based on most recent record */
estimatePrice(medicineId: string, householdId: string, storeId?: string): Promise<number | null>;
/** Spending analytics over time */
getAnalytics(householdId: string, period: 'month' | 'quarter' | 'year'): Promise<MedicineSpendingAnalytics>;
getPriceAnalytics(householdId: string, period: 'month' | 'quarter' | 'year'): Promise<PriceAnalytics>;
}
```
### 4.4 — Refill Alert Service
Analytics on this module are **price trends**, not spending — they show how prices change over time, not purchases made.
### 4.4 — Purchases Module
A purchase records that items were actually acquired. A single purchase can contain any mix of medicine products and (Phase 9) food products from the same store.
**Record physical purchase** (default flow — store visit):
- Set `status: 'in_cabinet'` at creation time
- Items are added to cabinet immediately as part of the create call
- No separate receive step needed
**Record online purchase**:
- Set `status: 'ordered'` at creation time
- Items are NOT added to cabinet yet
- Items in `ordered` purchases are counted as pending stock in refill alerts
- When items arrive, call `POST /purchases/:id/receive` to add to cabinet
**Receive flow** (`POST /purchases/:id/receive`):
1. For each item with `medicineProductId` and `addedToCabinet: false`:
- Create a `CabinetItem` (status: active, purchaseDate: today)
- If `actualPrice` was recorded on the item, create a `MedicinePriceRecord`
- Mark `addedToCabinet: true`
2. Set purchase status to `in_cabinet`
3. Return summary: `{ addedCount, priceRecordsCreated }`
### 4.5 — Refill Alert Service
Refill alerts account for both current cabinet stock and pending orders (status `ordered`) so users are not prompted to reorder medicine already on its way.
```typescript
class RefillAlertService {
/**
* For each medicine in the user's active regimens:
* 1. Get burn rate from BurnRateService (Phase 3)
* 2. If daysUntilEmpty <= thresholdDays, create alert
* 3. Attach last known price + cheapest store option
* 4. Calculate suggested quantity (enough for configurable days, default 30)
* 2. Sum cabinet stock + units in purchases with status 'ordered'
* 3. If daysUntilEmpty (cabinet only) <= thresholdDays, create alert
* 4. Attach pending purchase stock, cheapest price option, last known price
* 5. Calculate suggested quantity (enough for configurable days, default 30)
*/
getAlerts(householdId: string, userId: string, thresholdDays?: number): Promise<RefillAlert[]>;
}
```
### 4.5 — Refill Lists
### 4.6 — Refill Lists
- CRUD for refill lists
- `POST /refills/lists` with optional `fromAlerts: true` to auto-populate from current alerts
- Each item can have an estimated price (from price history) and an actual price (entered when purchased)
- Store comparison: for each item, find cheapest store based on recent price records
### 4.6 — Refill to Cabinet Flow
- `POST /refills/lists/:id/add-to-cabinet`:
- For each checked item with `addedToCabinet: false`:
- Create a `CabinetItem` (status: active, purchaseDate: today)
- If `actualPrice` was entered, create a `MedicinePriceRecord`
- Mark `addedToCabinet: true`
- Return summary: `{ addedCount, priceRecordsCreated }`
- Each item can carry an estimated price (from price history) and an actual price (entered when purchased)
- Refill lists are planning aids — actual cabinet addition goes through Purchases
### 4.7 — Price Analytics
```typescript
interface MedicineSpendingAnalytics {
/** Total spending per period */
spendingOverTime: { period: string; total: number }[];
Analytics on the medicine-prices page show **price trends across stores over time**, not spending:
/** Most expensive medicines */
topBySpending: {
```typescript
interface PriceAnalytics {
priceOverTime: { period: string; avgPricePerUnit: number; storeName: string }[];
cheapestByMedicine: {
medicineId: string;
medicineName: string;
totalSpent: number;
avgPricePerUnit: number;
}[];
/** Per-store spending */
spendingByStore: {
storeId: string;
cheapestPricePerUnit: number;
storeName: string;
totalSpent: number;
purchaseCount: number;
}[];
/** Price trend alerts (significant increases) */
priceAlerts: {
medicineId: string;
medicineName: string;
@ -314,49 +376,44 @@ interface MedicineSpendingAnalytics {
}
```
### 4.8 — Web UI: Pharmacies & Prices
Spending analytics (total money spent) live on the Orders page, computed from order items with `actualPrice`.
- `/stores` page:
- Store list with CRUD
- Filter by tags (pharmacy, grocery, etc.)
- Per-store summary: total spent, last visit, item count
### 4.8 — Web UI: Stores & Prices
- `/stores` page: store list with CRUD, filter by tags
- `/medicine-prices` page:
- Medicine search -> price history line chart (per store, color-coded)
- Record a price: store → medicine → product → price + quantity + currency + insurance flag + notes
- Price history per medicine: filterable by store, shows price-per-unit over time
- Store comparison table for selected medicine
- Spending over time bar chart
- Price alert panel
- Price trend analytics (price changes over time, not spending)
### 4.9 — Web UI: Refill Management
### 4.9 — Web UI: Purchases
- `/purchases` page:
- List of purchases grouped by status (ordered / in cabinet)
- Record purchase: pick store, choose physical or online, add line items (medicine products)
- Physical purchases: items go to cabinet immediately on save
- Online purchases: sit in `ordered` state until received; "Mark as received" bulk-adds to cabinet
- Shows actual price paid per item (used to auto-create price records on receive)
### 4.10 — Web UI: Refill Management
- `/refills` page:
- **Alerts section**: medicines running low
- Card per medicine: name, days remaining, suggested quantity, cheapest store
- "Generate Refill List" button -> creates list from all alerts
- **Refill lists section**:
- Active lists at top, completed/archived below
- List detail view:
- Items with checkbox, medicine name, quantity, estimated price
- Check off: optionally enter actual price
- Store comparison panel
- "Done Shopping" -> prompts "Add items to cabinet?"
- **Dashboard widget**: refill alert count badge, medicines needing refill soon
- **Alerts section**: medicines running low, with `daysUntilEmpty` and `daysUntilEmptyWithOrders` shown separately so the user can see how much the pending order helps
- **Refill lists section**: planning lists with check-off and estimated prices
---
## Acceptance Criteria
- [ ] Can create and manage stores with tags
- [ ] Can record medicine prices and view price history
- [ ] Store comparison shows cheapest option per medicine
- [ ] Refill alerts correctly identify medicines running low based on burn rate
- [ ] Refill lists can be auto-generated from alerts
- [ ] Checked refill items can be added to cabinet in one action
- [ ] Price analytics show spending trends
- [ ] Can record catalog price observations; multiple price points per product+store allowed
- [ ] Store comparison shows cheapest observed price per medicine
- [ ] Can record a purchase (physical or online) mixing medicine and (future) food items
- [ ] Physical purchases add medicine items to cabinet immediately
- [ ] Online purchases sit in ordered state; receiving them adds items to cabinet and optionally records prices
- [ ] Refill alerts correctly factor in pending purchases (status: ordered) when computing days-until-empty
- [ ] Price analytics show price trends, not spending
- [ ] Spending analytics live on the Orders page
- [ ] Store infrastructure is reusable for food tracking (Phase 9)
- [ ] All queries scoped to `householdId`
---
## Estimated Effort
Medium-large. Store/price infrastructure, refill alert logic, and the refill-to-cabinet flow involve significant work. The store comparison and analytics add moderate complexity.