16 KiB
Phase 4 — Pharmacies, Prices, Purchases & Refills
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)
Domain Model
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 toin_cabinetand 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.
Data Model
Store Schema (Shared — used by both medicine and food domains)
// packages/shared/src/types/store.ts
export interface Store {
id: string;
householdId: string;
name: string;
address?: string;
location?: { lat: number; lng: number };
url?: string;
notes?: string;
tags: string[]; // e.g. 'pharmacy', 'grocery', 'online', 'bulk', 'discount'
isActive: boolean;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
PriceRecord Schema (Medicine)
// 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
storeId: string;
storeName: string; // denormalized
price: number;
currency: string;
quantity: number; // units in this price point (package size)
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
// 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)
// 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
dailyConsumption: number;
currentStock: number;
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;
};
lastKnownPrice?: {
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
// Store
{ householdId: 1, name: 1 }
{ householdId: 1, tags: 1 }
// MedicinePriceRecord
{ householdId: 1, medicineProductId: 1, storeId: 1, date: -1 }
{ 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 }
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 |
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 |
Query Parameters
# GET /stores
?tags=pharmacy
&search=walgreens
# GET /medicine-prices/history/:medicineId
?storeId=abc123
&startDate=2026-01-01
&endDate=2026-03-27
&limit=50
# GET /purchases
?status=ordered
&storeId=abc123
# GET /refills/alerts
?thresholdDays=7
&userId=abc123
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 - 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
4.3 — Medicine Price Service
Records catalog price observations. Multiple records per product+store are allowed (different pack sizes, bulk tiers, insurance pricing).
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[]>;
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>;
}
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
orderedpurchases are counted as pending stock in refill alerts - When items arrive, call
POST /purchases/:id/receiveto add to cabinet
Receive flow (POST /purchases/:id/receive):
- For each item with
medicineProductIdandaddedToCabinet: false:- Create a
CabinetItem(status: active, purchaseDate: today) - If
actualPricewas recorded on the item, create aMedicinePriceRecord - Mark
addedToCabinet: true
- Create a
- Set purchase status to
in_cabinet - 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.
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)
*/
getAlerts(householdId: string, userId: string, thresholdDays?: number): Promise<RefillAlert[]>;
}
4.6 — Refill Lists
- CRUD for refill lists
POST /refills/listswith optionalfromAlerts: trueto 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
4.7 — Price Analytics
Analytics on the medicine-prices page show price trends across stores over time, not spending:
interface PriceAnalytics {
priceOverTime: { period: string; avgPricePerUnit: number; storeName: string }[];
cheapestByMedicine: {
medicineId: string;
medicineName: string;
cheapestPricePerUnit: number;
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.
4.8 — Web UI: Stores & Prices
/storespage: store list with CRUD, filter by tags/medicine-pricespage:- 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
- Price trend analytics (price changes over time, not spending)
4.9 — Web UI: Purchases
/purchasespage:- 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
orderedstate 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
/refillspage:- Alerts section: medicines running low, with
daysUntilEmptyanddaysUntilEmptyWithOrdersshown separately so the user can see how much the pending order helps - Refill lists section: planning lists with check-off and estimated prices
- Alerts section: medicines running low, with
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
- Store infrastructure is reusable for food tracking (Phase 9)
- All queries scoped to
householdId