In server/controllers/shopController.js, the purchaseItem function fetches the item with findById, checks item.stock === 0 to decide if it is out of stock, then later in the same function decrements item.stock and calls item.save().
These are separate steps with no locking between them, so under concurrent requests for the same item, multiple purchases can read the same stock value before any of them save their update. Each request individually passes the out of stock check and proceeds to debit points and create a purchase record, meaning a limited stock item with only one unit left can be sold to multiple users at once.
This is the same category of problem that debitPoints in server/services/pointService.js already solves correctly for point balances, using an atomic findOneAndUpdate with a points greater than or equal to amount condition so a user can never spend points they do not have even under concurrent requests. The shop stock check does not use the same pattern, so it remains vulnerable to overselling.
Suggested fix: change the stock check and decrement in purchaseItem to a single atomic operation, for example a findOneAndUpdate on ShopItem with a condition like stock greater than 0 or stock equal to negative 1 for unlimited items, combined with the decrement in the same call, mirroring the approach already used in debitPoints.
In server/controllers/shopController.js, the purchaseItem function fetches the item with findById, checks item.stock === 0 to decide if it is out of stock, then later in the same function decrements item.stock and calls item.save().
These are separate steps with no locking between them, so under concurrent requests for the same item, multiple purchases can read the same stock value before any of them save their update. Each request individually passes the out of stock check and proceeds to debit points and create a purchase record, meaning a limited stock item with only one unit left can be sold to multiple users at once.
This is the same category of problem that debitPoints in server/services/pointService.js already solves correctly for point balances, using an atomic findOneAndUpdate with a points greater than or equal to amount condition so a user can never spend points they do not have even under concurrent requests. The shop stock check does not use the same pattern, so it remains vulnerable to overselling.
Suggested fix: change the stock check and decrement in purchaseItem to a single atomic operation, for example a findOneAndUpdate on ShopItem with a condition like stock greater than 0 or stock equal to negative 1 for unlimited items, combined with the decrement in the same call, mirroring the approach already used in debitPoints.