MeshiTrack/docs/phases/phase-4-pharmacies-prices-refills.md

420 lines
16 KiB
Markdown
Raw Permalink Normal View History

# Phase 4 — Pharmacies, Prices, Purchases & Refills
2026-03-27 14:50:34 +09:00
**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).
2026-03-27 14:50:34 +09:00
**Depends on**: Phase 0, Phase 1 (medicines), Phase 2 (cabinet), Phase 3 (regimens — for burn rate)
---
## Domain Model
2026-03-27 14:50:34 +09:00
### 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.
2026-03-27 14:50:34 +09:00
---
## Data Model
### Store Schema (Shared — used by both medicine and food domains)
```typescript
// packages/shared/src/types/store.ts
export interface Store {
id: string;
householdId: string;
name: string;
address?: string;
location?: { lat: number; lng: number };
2026-03-27 14:50:34 +09:00
url?: string;
notes?: string;
tags: string[]; // e.g. 'pharmacy', 'grocery', 'online', 'bulk', 'discount'
2026-03-27 14:50:34 +09:00
isActive: boolean;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
```
### PriceRecord Schema (Medicine)
```typescript
// packages/shared/src/types/medicine-price.ts
export interface MedicinePriceRecord {
id: string;
householdId: string;
medicineProductId: string; // specific brand/package
medicineProductBrand: string; // denormalized
medicineId: string; // generic medicine, for cross-brand comparison
medicineName: string; // denormalized
2026-03-27 14:50:34 +09:00
storeId: string;
storeName: string; // denormalized
2026-03-27 14:50:34 +09:00
price: number;
currency: string;
quantity: number; // units in this price point (package size)
2026-03-27 14:50:34 +09:00
unit: DosageUnit;
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[];
2026-03-27 14:50:34 +09:00
notes?: string;
purchasedAt: Date; // when the purchase was made (or order placed)
receivedAt?: Date; // set when status moves to in_cabinet for online orders
2026-03-27 14:50:34 +09:00
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;
2026-03-27 14:50:34 +09:00
}
```
### RefillAlert (Computed, not stored)
```typescript
// packages/shared/src/types/refill.ts
export interface RefillAlert {
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: StrengthUnit;
daysUntilEmpty: number; // based on cabinet stock only
daysUntilEmptyWithOrders: number; // cabinet + pending ordered items
2026-03-27 14:50:34 +09:00
dailyConsumption: number;
currentStock: number;
pendingOrderStock: number; // units in orders with status 'ordered'
suggestedQuantity: number; // enough for N days (configurable, default 30)
cheapestOption?: {
2026-03-27 14:50:34 +09:00
price: number;
pricePerUnit: number;
storeName: string;
storeId: string;
date: Date;
};
lastKnownPrice?: {
2026-03-27 14:50:34 +09:00
price: number;
pricePerUnit: number;
storeName: string;
storeId: string;
date: Date;
};
}
export interface RefillList {
id: string;
householdId: string;
name: string;
items: RefillListItem[];
status: RefillListStatus;
preferredStoreId?: string;
totalEstimatedCost?: number;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export interface RefillListItem {
id: string;
medicineId: string;
medicineName: string;
quantity: number;
unit: DosageUnit;
estimatedPrice?: number;
actualPrice?: number;
checked: boolean;
checkedAt?: Date;
addedToCabinet: boolean;
storeId?: string;
notes?: string;
}
export enum RefillListStatus {
ACTIVE = 'active',
SHOPPING = 'shopping',
COMPLETED = 'completed',
ARCHIVED = 'archived',
}
```
### MongoDB Indexes
```javascript
// Store
{ householdId: 1, name: 1 }
{ householdId: 1, tags: 1 }
// MedicinePriceRecord
2026-03-28 08:19:48 +09:00
{ householdId: 1, medicineProductId: 1, storeId: 1, date: -1 }
{ householdId: 1, medicineId: 1, date: -1 }
2026-03-27 14:50:34 +09:00
{ householdId: 1, storeId: 1, date: -1 }
// Purchase
{ householdId: 1, status: 1, purchasedAt: -1 }
{ householdId: 1, storeId: 1 }
{ householdId: 1, 'items.medicineProductId': 1 }
2026-03-27 14:50:34 +09:00
// RefillList
{ householdId: 1, status: 1 }
{ householdId: 1, createdAt: -1 }
```
---
## API Endpoints
### StoresModule (Shared)
| Method | Path | Description | Auth |
| ------ | ------------- | ------------------------- | ------ |
| GET | `/stores` | List stores for household | member |
| GET | `/stores/:id` | Get single store | member |
| POST | `/stores` | Add a store | member |
| PATCH | `/stores/:id` | Update store | member |
| DELETE | `/stores/:id` | Deactivate store | admin |
### MedicinePricesModule
| 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 |
2026-03-27 14:50:34 +09:00
### RefillsModule
| 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 |
2026-03-27 14:50:34 +09:00
### Query Parameters
```
# GET /stores
?tags=pharmacy
&search=walgreens
2026-03-27 14:50:34 +09:00
# GET /medicine-prices/history/:medicineId
?storeId=abc123
&startDate=2026-01-01
2026-03-27 14:50:34 +09:00
&endDate=2026-03-27
&limit=50
# GET /purchases
?status=ordered
&storeId=abc123
2026-03-27 14:50:34 +09:00
# GET /refills/alerts
?thresholdDays=7
&userId=abc123
2026-03-27 14:50:34 +09:00
```
---
## Tasks
### 4.1 — Shared Types & Validation
- 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`
2026-03-27 14:50:34 +09:00
- Zod schemas for all create/update operations
### 4.2 — Stores CRUD (Shared Infrastructure)
- `packages/api/src/modules/stores/`
- Standard CRUD, scoped to `householdId`
- Tag-based filtering (pharmacy, grocery, online, etc.)
- Reused by both medicine and food domains
2026-03-27 14:50:34 +09:00
### 4.3 — Medicine Price Service
Records catalog price observations. Multiple records per product+store are allowed (different pack sizes, bulk tiers, insurance pricing).
2026-03-27 14:50:34 +09:00
```typescript
class MedicinePriceService {
recordPrice(data: CreateMedicinePriceRecord, householdId: string): Promise<MedicinePriceRecord>;
getPriceHistory(medicineId: string, householdId: string, options?: { storeId?: string; startDate?: Date; endDate?: Date; limit?: number }): Promise<MedicinePriceRecord[]>;
2026-03-27 14:50:34 +09:00
compareStores(medicineId: string, householdId: string): Promise<StoreComparison[]>;
estimatePrice(medicineId: string, householdId: string, storeId?: string): Promise<number | null>;
getPriceAnalytics(householdId: string, period: 'month' | 'quarter' | 'year'): Promise<PriceAnalytics>;
2026-03-27 14:50:34 +09:00
}
```
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.
2026-03-27 14:50:34 +09:00
```typescript
class RefillAlertService {
/**
* For each medicine in the user's active regimens:
* 1. Get burn rate from BurnRateService (Phase 3)
* 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)
2026-03-27 14:50:34 +09:00
*/
getAlerts(householdId: string, userId: string, thresholdDays?: number): Promise<RefillAlert[]>;
}
```
### 4.6 — Refill Lists
2026-03-27 14:50:34 +09:00
- CRUD for refill lists
- `POST /refills/lists` with optional `fromAlerts: true` to auto-populate from current alerts
- 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
2026-03-27 14:50:34 +09:00
### 4.7 — Price Analytics
Analytics on the medicine-prices page show **price trends across stores over time**, not spending:
2026-03-27 14:50:34 +09:00
```typescript
interface PriceAnalytics {
priceOverTime: { period: string; avgPricePerUnit: number; storeName: string }[];
cheapestByMedicine: {
2026-03-27 14:50:34 +09:00
medicineId: string;
medicineName: string;
cheapestPricePerUnit: number;
2026-03-27 14:50:34 +09:00
storeName: string;
}[];
priceAlerts: {
medicineId: string;
medicineName: string;
storeName: string;
previousPrice: number;
currentPrice: number;
changePercent: number;
}[];
}
```
Spending analytics (total money spent) live on the Orders page, computed from order items with `actualPrice`.
2026-03-27 14:50:34 +09:00
### 4.8 — Web UI: Stores & Prices
- `/stores` page: store list with CRUD, filter by tags
2026-03-27 14:50:34 +09:00
- `/medicine-prices` page:
- 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
2026-03-27 14:50:34 +09:00
- Store comparison table for selected medicine
- Price trend analytics (price changes over time, not spending)
### 4.9 — Web UI: Purchases
2026-03-27 14:50:34 +09:00
- `/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
2026-03-27 14:50:34 +09:00
- `/refills` page:
- **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
2026-03-27 14:50:34 +09:00
---
## Acceptance Criteria
- [ ] Can create and manage stores with tags
- [ ] 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
2026-03-27 14:50:34 +09:00
- [ ] Store infrastructure is reusable for food tracking (Phase 9)
- [ ] All queries scoped to `householdId`