Orders & fulfilment
Click-and-Collect In-Store Pickup
Indicative price, not an offer: $199–399/year
Generated from docs/plan/paid/click-and-collect-in-store-pickup/PLAN.md in the AstroBaaS repository. Nothing described below is implemented — it is the written plan for it.
Merchants define physical store locations and assign stock to them. Customers select pickup location at checkout; orders route to the correct store and staff receive location-aware pick lists. Inventory is reserved per location, preventing oversells.
The problem
My customers want to buy online and pick up in-store, but I cannot reserve stock per location. I have three shops; every order goes nowhere and I manually sort parcels after the fact. No route information for staff.
What it does
- Create Location entity: name, address, phone, hours (JSON for open/close per day), location_id, is_active
- Extend stock model: add location_id field. Stock is now per-location, not global. Allocation logic checks location stock.
- Extend Order: add delivery_location_id, delivery_method (ship, pickup), estimated_ready_date (staff input after fulfillment)
- Checkout flow: show location selector before payment if pickup method is available. Filter locations by distance (merchant config: radius in km) or show all.
- Inventory check: at checkout, reserve stock from selected location. Oversell protection: stock must not go negative per location.
- Admin: Locations screen lists all stores, edit name/address/hours, toggle active. Stock management view filtered by location. Pick list shows ‘Ready for pickup at [location]’ and customer name.
- REST: GET /locations (public, filtered by is_active), POST /locations (staff), PUT /locations/:id (staff), DELETE /locations/:id (staff)
- REST: POST /orders with {delivery_location_id, delivery_method=‘pickup’} reserves stock from that location
- Notification: SMS/email to customer with ready date and location details (uses existing email layer)
- Pickup completion: staff mark order as ‘picked_up’ from admin; inventory is decremented only then (not at order creation)
- Reporting: Location stock levels, pickup readiness by location, pickup orders per day
What it deliberately does NOT do
Each boundary carries its reason. A boundary without a reason gets crossed by the next person who reads this.
- Distance-based location ranking: out of scope—merchant chooses which locations are selectable. Reason: geolocation API requires permissions and privacy policy; merchants configure manually.
- BOPIS fulfillment automation (auto-route to nearest): out of scope—staff manually select pickup location. Reason: routing requires MSI (Multi-Source Inventory) and complex allocation logic; deferred to multi-warehouse feature.
- Customer notifications on delay: out of scope—staff manually send ‘not ready’ notifications. Reason: requires templated SMS/push layer; email-only for now.
- Curbside pickup / QR code arrival tracking: out of scope. Reason: adds mobile app scope; pickup is location dropoff only.
- Subscription-based location sync (e.g., POS integration): out of scope. Reason: requires API credentials and ongoing support; phase 2.
- Tax nexus per location: out of scope—uses order shipping address for tax, not pickup location. Reason: tax engine out of scope; merchant handles location-specific tax.
Data model
Migration: new Location table/collection (location_id, name, address, phone, hours JSON, is_active, created_at). Add location_id to Stock table. Add delivery_location_id, delivery_method, ready_date to Order. Assumption: Stock already models quantities; Order already has delivery fields.
API
- GET /locations (public, returns active locations with address, phone, hours)
- POST /locations (staff, body: {name, address, phone, hours}) → {location_id}
- PUT /locations/:id (staff, body: {name, address, phone, hours, is_active}) → {location_id}
- DELETE /locations/:id (staff) → 204
- GET /stock/:product_id/locations (public, returns {location_id, name, quantity_available})
- POST /orders (body: {… delivery_location_id, delivery_method=‘pickup’}) → {order_id, reserved_location_id}
- PUT /orders/:id/ready_for_pickup (staff, body: {ready_date, notes}) → {order_id, ready_date}
- PUT /orders/:id/mark_picked_up (staff) → {order_id, picked_up_at}
Every route added here must also appear in src/pages/openapi.json.ts — a test
fails the build if it does not.
Admin
New Locations section: list stores with address, phone, hours, active toggle. Edit to change details. Stock view filtered by location tab. Order detail shows ‘Delivery method: Pickup at [location]’ and ‘Ready date’ (staff editable). Orders list has location filter. Pick list sorted by location.
The seam — why this is paid
Core owns Location entity, per-location stock reservation, and pickup-aware checkout. Paid pack owns: distance-based location ranking, fulfillment automation (auto-route based on MSI), curbside / mobile integration. Why: core provides honest single-location stock model; multi-location complexity (MSI, auto-routing) is paid.
Site-specific operational feature. Depends on MSI being installed. Requires location management, location-aware stock allocation, and delivery-method selection workflow.
Dependencies
- stock (must support location_id field)
- orders (must support delivery_location_id, delivery_method)
- address-book-with-billing-shipping-split (checkout needs address to validate shipping availability)
- shipments-with-tracking-and-carrier-hooks (pickup orders also need shipment entities for pick lists)
Acceptance checks
Each of these must be able to fail. Before claiming this is done, break the code deliberately and watch each one go red.
- Location ‘Main Store’ with 5 items in stock for product 123; checkout reserves 1; stock shows 4 remaining
- Checkout with 3 active locations shows all three in pickup location selector; inactive location is hidden
- Order with delivery_method=‘pickup’ and delivery_location_id=loc_2 reserves from loc_2, not loc_1
- Pick list at location shows ‘Ready for pickup: Jane Doe (Order #1234)’; location_id matches order delivery_location_id
- Attempting to reserve more items than location stock (5 in stock, order for 10) fails with 400 inventory.insufficient_at_location
- Staff mark order ‘picked_up’; subsequent GET /orders/123 shows picked_up_at timestamp
- Deleting a location does not cascade-delete orders; orders show location_id but staff UI handles orphaned location gracefully
- Hours JSON {mon: ‘09:00-17:00’, tue: null, …} is stored and returned in GET /locations/:id
- Two concurrent checkouts for same location, same product: first reserves 3, second reserves 2 of 5; both succeed; third checkout for 1 should fail
- PUT /orders/:id/ready_for_pickup with ready_date=‘2026-09-05’ is stored and returned in order detail
Risks
Schema: stock model gains location_id—existing stock records have NULL or default location; migration must backfill existing inventory to a ‘default’ location or data is lost. Inventory: concurrent checkouts can oversell if reservation is not atomic; must test race conditions. Order state: orders with pickup_method must not allow ‘shipped’ status; staff could mark shipped instead of picked_up, breaking pickup workflow.
Commercial context
| Suggested price | $199–399/year |
| Rival anchor | Magento ships in-store delivery free, built on MSI sources as pickup locations. |
The anchor is what the nearest equivalent charges on Shopify or Magento today. It is context for a pricing decision, not the decision.
Generated from the commerce plan. See docs/COMMERCE-PLAN.md for the full
catalogue and ../../AI-GUIDE.md for how to work on this repository.