max / makenotwork
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 | + | } |