Appearance
Stock Management
Overview
Stock Management is the behavioural side of inventory: how tracked items are deducted when orders are placed, how stock is gated at checkout, and how out-of-stock items behave on Online Ordering and Kiosk. The underlying data (records, history, the Overview/History screens) is described in Inventory.
Key Purpose: Keep customer-facing availability correct by deducting stock on orders, blocking over-selling, and respecting per-order and out-of-stock rules.
Purpose
This covers what happens when stock changes: automatic deduction on committed orders, the checkout stock check, per-order maximum enforcement, and edge synchronisation.
Key Concepts
- Tracking = a record exists: An item is stock-managed only when it has an inventory record at that location.
Item::getTrackQuantity()returns(bool) getInventory()(RawModels/Item.php:544). There is no stored "track stock" toggle. - Stock = replayed history: The current quantity is recomputed from the inventory history (
Restockadds,Bought/Depletionsubtract) rather than read from a single mutable counter (Services/Inventory/InventoryService.php:56). - Untracked items are unlimited: An item with no inventory record is never blocked;
Item::getStock()returns 99999 in that case (RawModels/Item.php:529). - Max Order Limit: A per-item cap on quantity in a single order, enforced when stock is managed (
InventoryService.php:344,Item::getMaxOrderLimit()RawModels/Item.php:264). - D1 Sync + ReloadMenu: After any stock change the new quantity is pushed to Cloudflare D1 and a
ReloadMenuevent fires so ordering channels reflect availability (InventoryService.php:227). - Stock reservations (holds): While a shopper builds a cart on Online Ordering or Kiosk, the channel places a short-lived hold on the edge for each tracked item it adds, updates that hold when the quantity changes, and releases it when the item is removed or the bag is emptied. Holds are what those channels subtract before showing a shopper what is still available, and they are the only record of stock that is spoken for but not yet paid. Stored at the edge, not in the item's inventory history — a hold never writes a
Restock/Bought/Depletionentry. (Verified: zestidoo-online-orderingsrc/stores/transaction.tsandsrc/api/services/app-services.ts; upvendo-kiosksrc/stores/transaction.ts.) - Reservation-aware checkout check: The pre-payment stock check consults those holds, so two carts can no longer each pass a stock check against the same units. See Check Stock at Checkout for exactly when it applies.
- There is no per-item out-of-stock snooze: no
snooze_time_out_of_stock_itemsfield and noSnoozeOutOfStockItemOptionsenum exist anywhere in the backend. Every snooze in the product is a channel pause: the singleSnoozeOptionsenum (accept / 20 / 40 / 60 min / rest-of-day /dont_accept) drives the Online Ordering pause (SnoozeOnlineOrderingRequest,POST /back-office/online-ordering/{locationId}/snooze) and the Table QR pause (SnoozeQrOrderingRequest,POST /back-office/qr-ordering/{locationId}/snooze), which writeonline_ordering_setting.snoozed_untilandqr_ordering_setting.snoozed_untiland are independent of each other. Neither touches individual out-of-stock items; a tracked item simply becomes unavailable at quantity 0. (Verified:app/Enums/SnoozeOptions.phpis the only snooze enum underapp/Enums/;OnlineOrderingService::snooze,QrOrderingService::snooze;routes/api/backoffice/online-ordering.php:10,routes/api/backoffice/qr-ordering.php:11.)
Actions
Deduct Stock on an Order (automatic)
When a customer order is committed, the system groups ordered quantities per item (including linked items referenced by sub-modifiers), and for each tracked item writes a Bought history entry and recomputes the quantity (InventoryService.php:316). Untracked items are skipped.
Check Stock at Checkout
Before a payment starts, the system checks each tracked item and aborts with HTTP 400 "Not enough remaining stock for :itemName" if it cannot be served. Two checks run, in this order:
- Against other carts' holds. The system asks the edge how much of each item is available to this cart — the stock minus every live hold except the cart's own. (Excluding the cart's own holds is what makes the question answerable: the shopper's own reservations are for the very order being placed.) If that figure is below the quantity ordered, the order is refused.
- Against the stored quantity. If the remaining stock would go below zero and selling-without-stock is not allowed, the order is refused (
InventoryService.php:368).
The reservation check is deliberately narrow, and each limit matters when diagnosing a checkout:
- Pre-payment only. Once payment is captured the order is committed regardless — refusing a customer after taking their money is worse than overselling, so the commit path never gains a new way to fail.
- Not used when the location sells without stock. Such a location is not gating sales on stock at all, so it is never asked.
- Only for orders that carry a cart session. Online Ordering and Kiosk orders do; POS orders and server-side replays do not, and they skip this check rather than risk refusing a valid order.
- An item the edge does not know about is not refused. Absence means there is no edge record for it, not that zero are available.
- It fails open. If the edge is unreachable, the order falls back to the stored-quantity check above, which still runs. A connectivity blip never stops a merchant selling.
(Verified: upvendo-backend app/Services/Inventory/InventoryService.php — the check runs only when the call is the pre-payment one and selling-without-stock is off, and returns nothing when the cart session or location is missing; callers app/Services/Orchestrators/KioskOrchestrator.php line 240 and app/Services/Orchestrators/OnlineOrderingOrchestrator.php line 489. The edge read that excludes a cart's own holds is authenticated and served uncached, so a stale snapshot cannot understate holds: upvendo-backend-proxy src/stock-api-handler.js.)
Enforce Per-Order Maximum
If an item has a maxOrderLimit and the ordered quantity exceeds it, the order is rejected with a 400 stating the maximum allowed (InventoryService.php:344).
Adjust Stock Manually
Saving a new target quantity from the Overview screen derives a Restock or Depletion and recomputes the stock. See Inventory for the screen and payload details.
Location
- Back-office Routes:
/inventory/overviewand/inventory/history(nav targetinventory-overview). There is no bare/inventorypage. - Backend Controller:
app/Http/Controllers/Api/InventoryController.php - Stock service:
app/Services/Inventory/InventoryService.php - Vue Components:
src/views/inventory/Overview.vue,src/views/inventory/History.vue
Stock Operations
Restock (increase)
Saving a higher target quantity derives a Restock entry.
Current quantity: 10
Save target: 30
→ Restock entry of 20, new quantity 30Depletion (decrease)
Saving a lower target quantity derives a Depletion entry.
Current quantity: 30
Save target: 25
→ Depletion entry of 5, new quantity 25Bought (automatic deduction)
A committed order writes a Bought entry per tracked item.
Customer orders 2x Pizza (tracked)
Current quantity: 10
→ Bought entry of 2, new quantity 8Business Logic
Order Stock Flow
Order committed
│
▼
Group ordered quantity per item
(includes linked items via sub-modifiers)
│
▼
For each item:
├── maxOrderLimit set and exceeded? → abort 400 (max allowed)
│
├── item status inactive/unavailable/hidden?
│ ├── pre-commit check → abort 400 "currently unavailable"
│ └── commit phase → log warning, continue
│
└── has inventory record (tracked)?
├── No → skip (unlimited)
└── Yes → pre-payment check only, and only when NOT sell-without-stock:
│ available to this cart (stock minus other carts' holds)
│ ├── edge unreachable / no cart session / item not at edge
│ │ → skip this check, fall through
│ └── available < ordered → abort 400
│
remaining = current - ordered
├── remaining < 0 and not sell-without-stock (check) → abort 400
└── commit → write Bought entry, recompute quantity
(logs a warning if it goes negative)Availability on the Storefront
Customer-facing availability is derived from the recomputed stock and expiry. Item::getStock(forCustomerFacing: true) returns 0 for expired stock and the quantity otherwise; an item with no inventory record returns 99999 (always available) (RawModels/Item.php:529). After any change, D1 is updated and ReloadMenu fires so channels refresh.
While a shopper builds a cart, Online Ordering and Kiosk place holds on the tracked items in it, and the availability those channels display is net of every live hold — so a shopper is not shown units another shopper has already reserved.
Storefront-only: exactly how an out-of-stock item is shown or hidden, and how each channel presents a hold it could not place, is handled by the Online Ordering and Kiosk surfaces and is not verified here.
Customer Impact
Online Ordering & Kiosk
- A tracked item at quantity 0 is treated as out of stock; how each storefront presents it is surface behaviour, not a backend setting.
- An expired record reports 0 stock for customers even if a quantity is stored.
- Untracked items (no inventory record) are always available.
- Stock changes propagate via Cloudflare D1 +
ReloadMenu, so channels reflect availability after a change. - Stock a shopper has in their basket is held for them, so the availability other shoppers see already excludes it.
- A customer can be refused at payment time for an item they were able to add, if another cart reserved the last units in between. They are stopped before paying, not after.
Relations
Depends On
- Inventory: Records and history that hold the quantity (see Inventory)
- Menu Items: Stock is keyed by item;
maxOrderLimitand status come from the item - Locations: Quantities are per location
Affects
- Online Ordering: Item availability and checkout gating
- Kiosk: Item availability
- Transactions: A committed order deducts stock (
Boughthistory) - Cloudflare D1: Edge availability cache
Related Features
- Inventory — data model, history, the Overview/History screens
- Menu Items
- Locations
Business Rules
- Stock is recomputed from inventory history (
Restockadds,Bought/Depletionsubtract); a negative result is rejected with 400 "Not enough remaining stock for :itemName" unless selling-without-stock is allowed (InventoryService.php:56,InventoryService.php:368). - Before a payment starts, a tracked item is also checked against the stock other carts are holding, and refused with the same 400 if the quantity ordered exceeds what is available to this cart. This runs only on the pre-payment check, only when selling-without-stock is off, and only for orders carrying a cart session (Online Ordering and Kiosk); it fails open to the stored-quantity check if the edge is unreachable, and does not gate an item the edge has no record of.
- Holds are edge-side only: reserving, changing or releasing a hold never writes an inventory history entry and never changes the stored quantity. Only a committed order does, via
Bought. - Only items with an inventory record are stock-gated; an item without one is unlimited (
Item::getStock()returns 99999,RawModels/Item.php:529). maxOrderLimit, when set, caps the quantity per single order and returns a 400 with the maximum when exceeded (InventoryService.php:344).- A committed order writes a
Boughthistory entry per tracked item and recomputes the quantity; at commit time a negative result is logged (not aborted) when selling-without-stock applies (InventoryService.php:370). - Every stock change pushes the new quantity to Cloudflare D1 and fires a
ReloadMenuevent (InventoryService.php:227).
FAQs
- "What happens when a customer orders the last unit?" The order writes a
Boughthistory entry and the quantity reaches 0; the item is then out of stock and each storefront decides how to present it. - "Can I sell an item that has no inventory record?" Yes — items without a record are not stock-gated and are treated as unlimited.
- "What is the difference between Depletion and Bought?"
Boughtis the automatic deduction when an order is committed;Depletionis a manual downward adjustment. - "How is a per-order quantity limit enforced?" Via the item's
maxOrderLimit; exceeding it returns a 400 with the maximum allowed. - "How fast do stock changes reach customers?" Each change updates Cloudflare D1 and fires
ReloadMenu, so channels reflect the new availability shortly after. - "Two customers were both able to order the last unit — how is that prevented?" Each cart places a hold on the items in it while the shopper is ordering, and the check that runs before payment starts compares the order against what is available once other carts' holds are subtracted. The second shopper is refused before paying rather than after.
- "A customer says an item was in their basket and then wouldn't let them pay." Another cart was holding the stock. The hold check runs at payment time, so an item can be addable and still unavailable moments later if someone else reserved the last units first.
- "Does an abandoned basket lock stock forever?" No. Holds are short-lived and are released when the item is removed or the bag is emptied; they never change the stored quantity, so an abandoned basket cannot reduce your stock.
- "Can I snooze a single out-of-stock item?" No. The only snooze controls are the Online Ordering and Table QR channel pauses, which pause the whole channel rather than one item.
Troubleshooting
Problem: Item shows out of stock incorrectly
Causes:
- Quantity is actually 0 (replay the history to confirm).
- The record has expired — expired stock reports 0 for customers.
- Edge cache (D1) not yet refreshed.
- Other shoppers are holding the units in their baskets. The stored quantity is unchanged, but what is available to a new shopper is lower.
Solutions:
- Save a higher target quantity (a
Restock). - Clear or extend the expiry on the record.
- Saving any change re-pushes to D1 and fires
ReloadMenu. - Nothing to fix — holds are short-lived and release on their own if those baskets are abandoned. Check the stored quantity in Overview, which holds never change.
Problem: Stock not deducting on orders
Causes:
- The item has no inventory record, so it is not tracked.
- The item was added under a different location.
Solutions:
- Add an inventory record for the item at the location.
- Confirm the item/location pairing.
Examples
Tracked item (stored record)
json
{
"item_id": "item-123",
"location_id": "location-amsterdam",
"quantity": 25
}A committed order of 2 writes a Bought entry and the quantity becomes 23.