Skip to main content

max / makenotwork

Apply a SyncKit cap increase now, not at the next renewal Every cap change queued to pending_storage_limit_bytes and waited for the renewal webhook, while update_synckit_app_sub_price re-quoted Stripe immediately with prorations. So raising a cap charged the higher price at once and handed over the storage up to a month later, and the user doing it is usually one already blocked by a full cap: they pay to be unblocked and stay blocked. Increases now set storage_limit_bytes directly and clear any pending change. Decreases still queue, which is the same asymmetry the proration already implies: the user paid for the period they are in and keeps it until the period ends.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-21 19:31 UTC
Signed with PGP, not checked
Commit: 752d1caccd60659a0d37c4b1568b7f35d72b1448
Parent: 8d7b81b
2 files changed, +54 insertions, -3 deletions
@@ -226,6 +226,38 @@
226 226 Ok(())
227 227 }
228 228
229 + /// Raise the active cap now, clearing any queued change.
230 + ///
231 + /// For increases only, and the asymmetry is the point. The price is re-quoted
232 + /// against Stripe immediately with prorations, so the user is charged for the
233 + /// larger cap the moment they ask for it; pending that to the next renewal
234 + /// would take the money now and hand over the storage up to a month later,
235 + /// which is the state a user hits precisely when they are already blocked by a
236 + /// full cap. Decreases still queue via `set_pending_storage_cap`: the user paid
237 + /// for the period they are in and keeps it until the period ends.
238 + #[tracing::instrument(skip_all)]
239 + pub async fn set_storage_cap_now(
240 + pool: &PgPool,
241 + user_id: UserId,
242 + app_id: SyncAppId,
243 + cap_bytes: i64,
244 + ) -> Result<()> {
245 + sqlx::query(
246 + r"
247 + UPDATE app_sync_subscriptions
248 + SET storage_limit_bytes = $3,
249 + pending_storage_limit_bytes = NULL
250 + WHERE user_id = $1 AND app_id = $2
251 + ",
252 + )
253 + .bind(user_id)
254 + .bind(app_id)
255 + .bind(cap_bytes)
256 + .execute(pool)
257 + .await?;
258 + Ok(())
259 + }
260 +
229 261 /// Promote a queued cap change to the active cap. Called from the renewal
230 262 /// webhook handler when Stripe rolls the subscription to a new period.
231 263 /// No-op if no pending change is queued.
@@ -453,15 +453,34 @@
453 453 )
454 454 .await?;
455 455
456 - db::synckit::set_pending_storage_cap(&db, sync_user.user_id, sync_user.app_id, req.cap_bytes)
456 + // An increase takes effect now, a decrease at the next renewal. Stripe was
457 + // just re-priced with prorations either way, so queueing an increase would
458 + // charge for headroom the user cannot use until the period rolls - and a
459 + // user raising their cap is usually one already blocked by a full one.
460 + let raising = req.cap_bytes > sub.storage_limit_bytes.unwrap_or(0);
461 + if raising {
462 + db::synckit::set_storage_cap_now(&db, sync_user.user_id, sync_user.app_id, req.cap_bytes)
463 + .await?;
464 + } else {
465 + db::synckit::set_pending_storage_cap(
466 + &db,
467 + sync_user.user_id,
468 + sync_user.app_id,
469 + req.cap_bytes,
470 + )
457 471 .await?;
472 + }
458 473
459 474 Ok(Json(SyncSubscriptionStatusResponse {
460 475 active: sub.status == "active",
461 476 tier: Some(sub.interval),
462 477 status: Some(sub.status),
463 - storage_limit_bytes: sub.storage_limit_bytes,
464 - pending_storage_limit_bytes: Some(req.cap_bytes),
478 + storage_limit_bytes: if raising {
479 + Some(req.cap_bytes)
480 + } else {
481 + sub.storage_limit_bytes
482 + },
483 + pending_storage_limit_bytes: (!raising).then_some(req.cap_bytes),
465 484 storage_used_bytes: Some(
466 485 db::synckit::storage_used_bytes(&db, sync_user.app_id, sync_user.user_id).await?,
467 486 ),