Skip to main content

max / makenotwork

payments: enum-type SyncKit billing_status, seal single-live-subscription invariant ultra-fuzz Run 6 --deep (drive Payments A- -> A+): - DbSyncAppBilling.billing_status -> SyncBillingStatus enum; retires the raw status-string comparisons across the synckit routes + conversions. - create_subscription cancels any stale past_due/trialing/incomplete row for the same (subscriber, project) before inserting, so a resubscribe can't leave two live rows (R6-Pay-N1). Keeps the ON CONFLICT DO NOTHING duplicate-webhook idempotency intact (the triple-unique index makes a widened-predicate DO UPDATE unsafe). Webhook-driven regression test added. - Document the platform-credit value-burn at the site (R6-Pay-N2); partial-balance redemption across all credits logged as a post-launch launchplan feature. - Incidental: fix a pre-existing needless_borrow clippy lint in workflows/auth.rs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-24 19:28 UTC
Commit: 9e4852fa628470f59cb5272b0d95a0eb2058b22a
Parent: d125534
12 files changed, +237 insertions, -18 deletions
@@ -0,0 +1,15 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "\n UPDATE subscriptions\n SET status = 'canceled', canceled_at = NOW(), updated_at = NOW()\n WHERE subscriber_id = $1 AND project_id = $2 AND item_id IS NULL\n AND status IN ('past_due', 'trialing', 'incomplete')\n ",
4 + "describe": {
5 + "columns": [],
6 + "parameters": {
7 + "Left": [
8 + "Uuid",
9 + "Uuid"
10 + ]
11 + },
12 + "nullable": []
13 + },
14 + "hash": "e405d0562681e2b3bb72b01c78d1084e44b3aed67a37ac792332a0154d7536d0"
15 + }
@@ -207,6 +207,33 @@ impl_str_enum!(SubscriptionStatus {
207 207 Unpaid => "unpaid",
208 208 });
209 209
210 + // ── SyncKit developer billing ──
211 +
212 + /// Lifecycle of a SyncKit developer app's billing record (the `sync_apps.billing_status`
213 + /// TEXT column, CHECK-constrained in migration 117). Replaces the raw string the
214 + /// `DbSyncAppBilling` model used to carry, so a status comparison can't drift from the
215 + /// CHECK set. (The sibling `enforcement_mode` column stays a string: the pricing layer
216 + /// and the DB use two vocabularies — `bulk` vs `app_wide` — whose reconciliation is a
217 + /// separate, deliberately deferred refactor.)
218 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219 + pub enum SyncBillingStatus {
220 + #[serde(rename = "draft")]
221 + Draft,
222 + #[serde(rename = "active")]
223 + Active,
224 + #[serde(rename = "suspended_unpaid")]
225 + SuspendedUnpaid,
226 + #[serde(rename = "canceled")]
227 + Canceled,
228 + }
229 +
230 + impl_str_enum!(SyncBillingStatus {
231 + Draft => "draft",
232 + Active => "active",
233 + SuspendedUnpaid => "suspended_unpaid",
234 + Canceled => "canceled",
235 + });
236 +
210 237 // ── Git repository visibility ──
211 238
212 239 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -132,8 +132,8 @@ pub struct DbSyncAppBilling {
132 132 pub stripe_customer_id: Option<String>,
133 133 /// Stripe Subscription ID; set once billing activates.
134 134 pub stripe_subscription_id: Option<String>,
135 - /// 'draft' | 'active' | 'suspended_unpaid' | 'canceled'
136 - pub billing_status: String,
135 + /// Billing lifecycle (draft / active / suspended_unpaid / canceled).
136 + pub billing_status: super::super::SyncBillingStatus,
137 137 /// Storage cap in GB. Set in bulk mode; NULL in per_key mode (capacity is
138 138 /// derived from `key_cap × gb_per_key`) and in draft.
139 139 pub storage_gb_cap: Option<i32>,
@@ -811,6 +811,13 @@ pub fn apply_promo_to_item(
811 811 if !validated.is_platform_wide && base_price_cents < code.min_price_cents {
812 812 return Ok(PromoApplication::Ineligible(PromoIneligible::BelowMinPrice));
813 813 }
814 + // KNOWN value-burn (R6-Pay-N2): a platform-wide fixed credit (e.g. the $5
815 + // Fan+ renewal credit) bypasses `min_price_cents` and `apply_discount`
816 + // clamps it to the item price, so applying a $5 credit to a $1 item consumes
817 + // the full credit ($4 lost). No platform money loss; fan-value only. The fix
818 + // is partial-balance redemption across all platform credits, tracked as a
819 + // launchplan feature ("Credit balances (partial redemption)") rather than a
820 + // promo-code patch here.
814 821 let (dt, dv) = match (code.discount_type, code.discount_value) {
815 822 (Some(dt), Some(dv)) => (dt, dv),
816 823 _ => {
@@ -224,16 +224,40 @@ pub async fn delete_subscription_tier(pool: &PgPool, id: SubscriptionTierId) ->
224 224 /// Returns `None` if the subscription already exists (duplicate webhook
225 225 /// or concurrent active subscription for the same user+project).
226 226 /// The partial UNIQUE index on `(subscriber_id, project_id) WHERE status = 'active'`
227 - /// prevents multiple active subscriptions at the DB level.
227 + /// prevents multiple *active* subscriptions at the DB level.
228 + ///
229 + /// Single-live-row invariant: before inserting, any lingering non-active-but-live
230 + /// row (`past_due`/`trialing`/`incomplete`) for the same `(subscriber, project)` is
231 + /// canceled, so a resubscribe over a stale row can't leave two live rows. The access
232 + /// gate (`SubscriptionGate::PREDICATE`) already ignores those statuses, so this is
233 + /// data hygiene, not an access fix. The cleanup is co-located here because this is the
234 + /// only INSERT path into `subscriptions`; widening the partial-unique predicate instead
235 + /// would collide with `idx_subscriptions_unique` (the `(subscriber, project,
236 + /// stripe_subscription_id)` triple) and break the `ON CONFLICT DO NOTHING`
237 + /// duplicate-webhook idempotency this path relies on. Takes `&mut PgConnection` (not a
238 + /// one-shot executor) so the cleanup and the insert run on the caller's transaction.
228 239 #[tracing::instrument(skip_all)]
229 - pub async fn create_subscription<'e>(
230 - executor: impl sqlx::PgExecutor<'e>,
240 + pub async fn create_subscription(
241 + conn: &mut sqlx::PgConnection,
231 242 subscriber_id: UserId,
232 243 tier_id: SubscriptionTierId,
233 244 project_id: ProjectId,
234 245 stripe_subscription_id: &str,
235 246 stripe_customer_id: &str,
236 247 ) -> Result<Option<DbSubscription>> {
248 + sqlx::query!(
249 + r#"
250 + UPDATE subscriptions
251 + SET status = 'canceled', canceled_at = NOW(), updated_at = NOW()
252 + WHERE subscriber_id = $1 AND project_id = $2 AND item_id IS NULL
253 + AND status IN ('past_due', 'trialing', 'incomplete')
254 + "#,
255 + subscriber_id as UserId,
256 + project_id as ProjectId,
257 + )
258 + .execute(&mut *conn)
259 + .await?;
260 +
237 261 let sub = sqlx::query_as!(
238 262 DbSubscription,
239 263 r#"
@@ -258,7 +282,7 @@ pub async fn create_subscription<'e>(
258 282 stripe_subscription_id,
259 283 stripe_customer_id,
260 284 )
261 - .fetch_optional(executor)
285 + .fetch_optional(&mut *conn)
262 286 .await?;
263 287
264 288 Ok(sub)
@@ -329,7 +329,7 @@ pub(super) async fn handle_subscription_checkout_completed(
329 329 let mut tx = state.db.begin().await.context("begin subscription webhook transaction")?;
330 330
331 331 let sub = match db::subscriptions::create_subscription(
332 - &mut *tx,
332 + &mut tx,
333 333 subscriber_id,
334 334 tier_id,
335 335 project_id,
@@ -48,7 +48,7 @@ pub(super) async fn setup(
48 48 if app.creator_id != user.id {
49 49 return Err(AppError::Forbidden);
50 50 }
51 - if app.billing_status != "draft" {
51 + if app.billing_status != crate::db::SyncBillingStatus::Draft {
52 52 return Err(AppError::Conflict(format!(
53 53 "App is already {}; billing setup is only valid in draft status",
54 54 app.billing_status
@@ -107,7 +107,7 @@ pub(super) async fn activate(
107 107 if app.creator_id != user.id {
108 108 return Err(AppError::Forbidden);
109 109 }
110 - if app.billing_status != "draft" {
110 + if app.billing_status != crate::db::SyncBillingStatus::Draft {
111 111 return Err(AppError::Conflict(format!(
112 112 "App is already {}; activate is only valid in draft status",
113 113 app.billing_status
@@ -179,7 +179,7 @@ pub(super) async fn patch(
179 179 if app.creator_id != user.id {
180 180 return Err(AppError::Forbidden);
181 181 }
182 - if app.billing_status != "active" {
182 + if app.billing_status != crate::db::SyncBillingStatus::Active {
183 183 return Err(AppError::Conflict(format!(
184 184 "App is {}; PATCH is only valid when active",
185 185 app.billing_status
@@ -240,7 +240,7 @@ pub(super) async fn cancel(
240 240 if app.creator_id != user.id {
241 241 return Err(AppError::Forbidden);
242 242 }
243 - if app.billing_status == "canceled" {
243 + if app.billing_status == crate::db::SyncBillingStatus::Canceled {
244 244 return Ok(axum::http::StatusCode::NO_CONTENT);
245 245 }
246 246
@@ -287,7 +287,7 @@ pub(super) async fn get(
287 287
288 288 Ok(Json(BillingStatusResponse {
289 289 app_id,
290 - billing_status: app.billing_status,
290 + billing_status: app.billing_status.to_string(),
291 291 is_internal: app.is_internal,
292 292 enforcement_mode: app.enforcement_mode,
293 293 storage_gb_cap: app.storage_gb_cap.map(|v| v as u32),
@@ -177,7 +177,7 @@ pub(super) async fn blob_confirm_upload(
177 177 )
178 178 .await?
179 179 } else {
180 - if billing.billing_status != "active" {
180 + if billing.billing_status != crate::db::SyncBillingStatus::Active {
181 181 return Ok((
182 182 StatusCode::PAYMENT_REQUIRED,
183 183 Json(json!({ "reason": "billing_inactive" })),
@@ -251,7 +251,7 @@ pub(super) async fn blob_download_url(
251 251 .await?
252 252 .ok_or(AppError::NotFound)?;
253 253 if !billing.is_internal {
254 - if billing.billing_status != "active" {
254 + if billing.billing_status != crate::db::SyncBillingStatus::Active {
255 255 return Ok((
256 256 StatusCode::PAYMENT_REQUIRED,
257 257 Json(json!({ "reason": "billing_inactive" })),
@@ -55,7 +55,7 @@ pub(super) async fn claim(
55 55 // concurrent claims of distinct keys can't over-allocate. `None` means
56 56 // uncapped (internal apps, or `bulk` developer apps).
57 57 let key_cap = if !billing.is_internal {
58 - if billing.billing_status != "active" {
58 + if billing.billing_status != crate::db::SyncBillingStatus::Active {
59 59 return Ok((
60 60 StatusCode::PAYMENT_REQUIRED,
61 61 Json(json!({ "reason": "billing_inactive" })),
@@ -506,7 +506,7 @@ pub async fn build_top_keys_map(
506 506 ) -> Result<std::collections::HashMap<db::SyncAppId, Vec<(String, i64)>>, crate::error::AppError> {
507 507 let per_key_app_ids: Vec<db::SyncAppId> = billing_map
508 508 .values()
509 - .filter(|b| b.enforcement_mode == "per_key" && b.billing_status == "active")
509 + .filter(|b| b.enforcement_mode == "per_key" && b.billing_status == crate::db::SyncBillingStatus::Active)
510 510 .map(|b| b.id)
511 511 .collect();
512 512 if per_key_app_ids.is_empty() {
@@ -645,7 +645,7 @@ impl super::dashboard::SyncAppBillingView {
645 645 let keys_pct = keys_pct.clamp(0, 200);
646 646
647 647 super::dashboard::SyncAppBillingView {
648 - status: b.billing_status.clone(),
648 + status: b.billing_status.to_string(),
649 649 is_internal: b.is_internal,
650 650 has_customer: b.stripe_customer_id.is_some(),
651 651 // Default to bulk for drafts so the panel renders sensibly even
@@ -43,7 +43,7 @@ async fn signup_with_taken_email_does_not_reveal_or_create() {
43 43 // A new username, but the same (taken) email.
44 44 h.client.fetch_csrf_token().await;
45 45 let body = "username=intruder&email=owner@example.com&password=password123";
46 - let resp = h.client.post_form("/join/step/account", &body).await;
46 + let resp = h.client.post_form("/join/step/account", body).await;
47 47
48 48 assert!(resp.status.is_success(), "taken-email signup should not error: {}", resp.status);
49 49 let lower = resp.text.to_lowercase();
@@ -1449,3 +1449,149 @@ async fn creator_tier_checkout_not_configured_rejected() {
1449 1449 resp.status, resp.text
1450 1450 );
1451 1451 }
1452 +
1453 + // ---------------------------------------------------------------------------
1454 + // Subscription single-live-row invariant (ultra-fuzz Run 6, R6-Pay-N1)
1455 + // ---------------------------------------------------------------------------
1456 +
1457 + /// Post a subscription `checkout.session.completed` webhook with explicit event
1458 + /// and Stripe-subscription IDs so two calls aren't deduped as a replay.
1459 + async fn post_subscription_webhook(
1460 + h: &mut TestHarness,
1461 + event_id: &str,
1462 + session_id: &str,
1463 + stripe_sub_id: &str,
1464 + subscriber_id: &str,
1465 + project_id: &str,
1466 + tier_id: &str,
1467 + ) -> crate::harness::client::TestResponse {
1468 + let object = serde_json::json!({
1469 + "id": session_id,
1470 + "subscription": stripe_sub_id,
1471 + "customer": "cus_n1_invariant",
1472 + "metadata": {
1473 + "checkout_type": "subscription",
1474 + "subscriber_id": subscriber_id,
1475 + "project_id": project_id,
1476 + "tier_id": tier_id,
1477 + }
1478 + });
1479 + let payload = serde_json::json!({
1480 + "id": event_id,
1481 + "type": "checkout.session.completed",
1482 + "data": {"object": object},
1483 + })
1484 + .to_string();
1485 + let signature = crate::harness::stripe::sign_webhook_payload(
1486 + &payload,
1487 + crate::harness::stripe::TEST_WEBHOOK_SECRET,
1488 + );
1489 + h.client
1490 + .request_with_headers(
1491 + "POST",
1492 + "/stripe/webhook",
1493 + Some(&payload),
1494 + &[
1495 + ("stripe-signature", &signature),
1496 + ("content-type", "application/json"),
1497 + ],
1498 + )
1499 + .await
1500 + }
1501 +
1502 + /// A resubscribe over a lingering `past_due` row must leave exactly one live
1503 + /// (active) subscription: the stale row is canceled by `create_subscription`'s
1504 + /// pre-insert cleanup, not left to coexist with the new active row.
1505 + #[tokio::test]
1506 + async fn subscription_resubscribe_after_past_due_leaves_one_live_row() {
1507 + let mut h = TestHarness::with_mocks().await;
1508 +
1509 + let seller_id = h.signup("n1seller", "n1seller@test.com", "pass1234").await;
1510 + h.grant_creator(seller_id).await;
1511 + sqlx::query(
1512 + "UPDATE users SET stripe_account_id = 'acct_n1', stripe_charges_enabled = true WHERE id = $1",
1513 + )
1514 + .bind(seller_id)
1515 + .execute(&h.db)
1516 + .await
1517 + .unwrap();
1518 +
1519 + h.client.post_form("/logout", "").await;
1520 + h.login("n1seller", "pass1234").await;
1521 + let resp = h
1522 + .client
1523 + .post_form("/api/projects", "slug=n1proj&title=N1+Project")
1524 + .await;
1525 + let project: Value = resp.json();
1526 + let project_id = project["id"].as_str().unwrap().to_string();
1527 + h.client
1528 + .put_json(&format!("/api/projects/{}", project_id), r#"{"is_public": true}"#)
1529 + .await;
1530 + sqlx::query(
1531 + r#"INSERT INTO subscription_tiers (project_id, name, price_cents, is_active, stripe_product_id, stripe_price_id)
1532 + VALUES ($1::uuid, 'Gold', 999, true, 'prod_n1', 'price_n1')"#,
1533 + )
1534 + .bind(&project_id)
1535 + .execute(&h.db)
1536 + .await
1537 + .unwrap();
1538 + let tier_id: String =
1539 + sqlx::query_scalar("SELECT id::text FROM subscription_tiers WHERE project_id = $1::uuid")
1540 + .bind(&project_id)
1541 + .fetch_one(&h.db)
1542 + .await
1543 + .unwrap();
1544 +
1545 + h.client.post_form("/logout", "").await;
1546 + let subscriber_id = h.signup("n1fan", "n1fan@test.com", "pass1234").await;
1547 + let subscriber_str = subscriber_id.to_string();
1548 +
1549 + // First subscription → one active row.
1550 + let r1 = post_subscription_webhook(
1551 + &mut h, "evt_n1_1", "cs_n1_1", "sub_n1_1", &subscriber_str, &project_id, &tier_id,
1552 + )
1553 + .await;
1554 + assert!(r1.status.is_success(), "first sub webhook: {} {}", r1.status, r1.text);
1555 +
1556 + // Stripe dunning leaves the row past_due (the gate ignores it, so the user
1557 + // appears unsubscribed and can resubscribe).
1558 + sqlx::query("UPDATE subscriptions SET status = 'past_due' WHERE stripe_subscription_id = 'sub_n1_1'")
1559 + .execute(&h.db)
1560 + .await
1561 + .unwrap();
1562 +
1563 + // Resubscribe: a brand-new Stripe subscription for the same (subscriber, project).
1564 + let r2 = post_subscription_webhook(
1565 + &mut h, "evt_n1_2", "cs_n1_2", "sub_n1_2", &subscriber_str, &project_id, &tier_id,
1566 + )
1567 + .await;
1568 + assert!(r2.status.is_success(), "second sub webhook: {} {}", r2.status, r2.text);
1569 +
1570 + let active_count: i64 = sqlx::query_scalar(
1571 + "SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND project_id = $2::uuid AND status = 'active'",
1572 + )
1573 + .bind(subscriber_id)
1574 + .bind(&project_id)
1575 + .fetch_one(&h.db)
1576 + .await
1577 + .unwrap();
1578 + assert_eq!(active_count, 1, "exactly one active subscription after resubscribe");
1579 +
1580 + let old_status: String = sqlx::query_scalar(
1581 + "SELECT status FROM subscriptions WHERE stripe_subscription_id = 'sub_n1_1'",
1582 + )
1583 + .fetch_one(&h.db)
1584 + .await
1585 + .unwrap();
1586 + assert_eq!(old_status, "canceled", "stale past_due row should be canceled");
1587 +
1588 + let total: i64 = sqlx::query_scalar(
1589 + "SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND project_id = $2::uuid",
1590 + )
1591 + .bind(subscriber_id)
1592 + .bind(&project_id)
1593 + .fetch_one(&h.db)
1594 + .await
1595 + .unwrap();
1596 + assert_eq!(total, 2, "old (canceled) + new (active)");
1597 + }