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

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 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.


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 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.

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/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

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

  • /stores page: store list with CRUD, filter by tags
  • /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
    • Store comparison table for selected medicine
    • Price trend analytics (price changes over time, not spending)

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, 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 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