fix(api): decrement SKU stock only when remaining quantity is sufficient
Prevent oversell by conditioning stock updates and locking SKUs in primary-key order, and record the same concurrent-counter rule in the API spec and agent guide. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
53548196f8
commit
835afbe5e0
@@ -184,11 +184,16 @@ pub async fn insert_item(
|
||||
}
|
||||
|
||||
pub async fn decrement_stock(tx: &mut PgConnection, sku_id: Uuid, qty: i32) -> ApiResult<()> {
|
||||
sqlx::query("UPDATE skus SET stock = stock - $2 WHERE id = $1")
|
||||
.bind(sku_id)
|
||||
.bind(qty)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE skus SET stock = stock - $2 WHERE id = $1 AND stock >= $2",
|
||||
)
|
||||
.bind(sku_id)
|
||||
.bind(qty)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::Conflict("insufficient stock".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -329,6 +334,7 @@ pub async fn lock_purchasable_skus(
|
||||
JOIN shops sh ON sh.id = p.shop_id
|
||||
WHERE s.id = ANY($1) AND s.active = TRUE
|
||||
AND p.status = 'published' AND sh.status = 'active'
|
||||
ORDER BY s.id
|
||||
FOR UPDATE OF s",
|
||||
)
|
||||
.bind(sku_ids)
|
||||
|
||||
@@ -79,6 +79,25 @@ async fn checkout_rejects_empty_cart() {
|
||||
assert!(matches!(err, ApiError::BadRequest(m) if m.contains("empty")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn decrement_stock_rejects_when_guard_fails() {
|
||||
let state = common::spawn_state().await;
|
||||
let sku_id = sellable_sku(&state, "guard", 100, 1).await;
|
||||
let mut tx = state.db.begin().await.unwrap();
|
||||
let err = order::repo::decrement_stock(&mut tx, sku_id, 2)
|
||||
.await
|
||||
.unwrap_err();
|
||||
tx.rollback().await.unwrap();
|
||||
assert!(matches!(err, ApiError::Conflict(m) if m.contains("insufficient stock")));
|
||||
let left: i32 = sqlx::query_scalar("SELECT stock FROM skus WHERE id = $1")
|
||||
.bind(sku_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(left, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn checkout_rejects_insufficient_stock() {
|
||||
|
||||
Reference in New Issue
Block a user