fix(api): record an elapsed group as expired when its opener cancels

Cancelling the opening unpaid order ran the expiry sweep only implicitly, so a
group whose lifetime had already ended was relabelled cancelled instead of
expired. Both conditions hold in that case; sweep first so the lifetime ending
wins. The behaviour was invisible to shoppers (neither state is joinable) but it
matters to a future refund flow reading expired paid groups.

Add an integration test that reproduces the precedence.
This commit is contained in:
2026-09-18 13:44:02 +00:00
parent cdfb1b06f2
commit d7fddf53ab
2 changed files with 33 additions and 1 deletions
+4 -1
View File
@@ -181,12 +181,15 @@ pub async fn link_opener(tx: &mut PgConnection, group_id: Uuid, order_id: Uuid)
repo::set_leader(tx, group_id, order_id).await repo::set_leader(tx, group_id, order_id).await
} }
/// An unpaid opener cancelling closes a group that has no paid members. /// An unpaid opener cancelling closes a group that has no paid members. The
/// expiry sweep runs first so an elapsed group records `expired` rather than
/// being relabelled `cancelled`.
pub async fn cancel_opener_group( pub async fn cancel_opener_group(
tx: &mut PgConnection, tx: &mut PgConnection,
order_id: Uuid, order_id: Uuid,
group_id: Uuid, group_id: Uuid,
) -> ApiResult<()> { ) -> ApiResult<()> {
repo::expire_due_groups(&mut *tx).await?;
repo::cancel_empty_opener_group(tx, group_id, order_id).await?; repo::cancel_empty_opener_group(tx, group_id, order_id).await?;
Ok(()) Ok(())
} }
+29
View File
@@ -468,6 +468,35 @@ async fn activity_rejects_a_sku_with_an_overlapping_flash_sale() {
assert_eq!(res.status(), 409); assert_eq!(res.status(), 409);
} }
#[tokio::test]
#[serial]
async fn cancelling_an_elapsed_empty_group_records_expired() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "gb-precedence", 1000, 10).await;
let activity = create_activity(&app, &owner, &sku, 700, 3, 24).await;
let (token, _) = register_customer(&app, "gb-precedence").await;
add_to_cart(&app, &token, &sku, 1).await;
let order = place(&app, &token, &sku, &activity, None).await;
let group_id = sqlx::query_scalar::<_, Uuid>("SELECT group_id FROM orders WHERE id = $1")
.bind(Uuid::parse_str(&order).unwrap())
.fetch_one(&app.db)
.await
.unwrap();
// The lifetime ends before the opener cancels.
sqlx::query("UPDATE collective_groups SET expires_at = now() - interval '1 hour' WHERE id = $1")
.bind(group_id)
.execute(&app.db)
.await
.unwrap();
assert_eq!(cancel(&app, &token, &order).await, 200);
// Both conditions hold; an elapsed group is expired, not relabelled cancelled.
assert_eq!(group_state(&app, &group_id.to_string()).await.0, "expired");
}
#[tokio::test] #[tokio::test]
#[serial] #[serial]
async fn cancelling_does_not_roll_back_paid_seats() { async fn cancelling_does_not_roll_back_paid_seats() {