//! Integration tests for SyncKit v2 developer billing. //! //! Exercises the full lifecycle against a real Postgres test DB and a mock //! Stripe provider: setup → activate → patch → cancel, plus the //! key-claim cap enforcement on the server-to-server endpoint. use crate::harness::TestHarness; use makenotwork::db::{SyncAppId, UserId}; use serde::Deserialize; use serde_json::json; use sqlx::PgPool; #[derive(Deserialize)] struct BillingSetupResp { stripe_customer_id: String, billing_portal_url: String, } #[derive(Deserialize)] struct BillingUpdatedResp { monthly_price_cents: i64, billing_status: String, stripe_subscription_id: Option, } #[derive(Deserialize)] struct BillingStatusResp { billing_status: String, is_internal: bool, enforcement_mode: String, storage_gb_cap: Option, key_cap: Option, gb_per_key: Option, monthly_price_cents: Option, } /// Insert a draft (non-internal) sync app and return its id + plaintext api_key. async fn create_draft_app(pool: &PgPool, user_id: UserId) -> (SyncAppId, String) { let api_key = "test-api-key-billing-integration"; let key_hash = crate::harness::hash_api_key(api_key); let key_prefix = &api_key[..8]; let app_id: SyncAppId = sqlx::query_scalar( r#" INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, is_internal, billing_status) VALUES ($1, 'BillingTest', $2, $3, FALSE, 'draft') RETURNING id "#, ) .bind(user_id) .bind(&key_hash) .bind(key_prefix) .fetch_one(pool) .await .expect("Failed to create draft sync app"); sqlx::query("INSERT INTO sync_app_usage_current (app_id) VALUES ($1) ON CONFLICT DO NOTHING") .bind(app_id) .execute(pool) .await .expect("Failed to seed usage row"); (app_id, api_key.to_string()) } #[tokio::test] async fn setup_creates_customer_and_returns_portal_url() { let mut h = TestHarness::with_mocks().await; let user_id = h.signup("dev1", "dev1@example.com", "Password1!").await; let (app_id, _) = create_draft_app(&h.db, user_id).await; let resp = h.client.post_json( &format!("/api/sync/apps/{}/billing/setup", app_id), "", ).await; assert_eq!(resp.status, 200, "setup failed: {}", resp.text); let body: BillingSetupResp = resp.json(); assert!(!body.stripe_customer_id.is_empty(), "expected a customer id"); assert!(body.billing_portal_url.contains("billing.stripe"), "got {}", body.billing_portal_url); // The setup call should have persisted the customer id. let persisted: Option = sqlx::query_scalar( "SELECT stripe_customer_id FROM sync_apps WHERE id = $1", ) .bind(app_id) .fetch_one(&h.db) .await .unwrap(); assert!(persisted.is_some()); } #[tokio::test] async fn activate_then_get_reports_active_status_and_price() { let mut h = TestHarness::with_mocks().await; let user_id = h.signup("dev2", "dev2@example.com", "Password1!").await; let (app_id, _) = create_draft_app(&h.db, user_id).await; // setup let resp = h.client.post_json( &format!("/api/sync/apps/{}/billing/setup", app_id), "", ).await; assert_eq!(resp.status, 200); // Activate in bulk mode at 100 GB. Price = 100 × $0.03 = $3.00. let resp = h.client.post_json( &format!("/api/sync/apps/{}/billing/activate", app_id), &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(), ).await; assert_eq!(resp.status, 200, "activate failed: {}", resp.text); let body: BillingUpdatedResp = resp.json(); assert_eq!(body.billing_status, "active"); assert_eq!(body.monthly_price_cents, 300, "100 GB bulk should be $3.00"); assert!(body.stripe_subscription_id.is_some()); // GET should agree let resp = h.client.get(&format!("/api/sync/apps/{}/billing", app_id)).await; assert_eq!(resp.status, 200); let status: BillingStatusResp = resp.json(); assert_eq!(status.billing_status, "active"); assert!(!status.is_internal); assert_eq!(status.enforcement_mode, "bulk"); assert_eq!(status.storage_gb_cap, Some(100)); assert_eq!(status.key_cap, None); assert_eq!(status.gb_per_key, None); assert_eq!(status.monthly_price_cents, Some(300)); } #[tokio::test] async fn activate_per_key_mode() { let mut h = TestHarness::with_mocks().await; let user_id = h.signup("dev2pk", "dev2pk@example.com", "Password1!").await; let (app_id, _) = create_draft_app(&h.db, user_id).await; h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await; // 50 keys × 2 GB = 100 GB equivalent → $3.00. let resp = h.client.post_json( &format!("/api/sync/apps/{}/billing/activate", app_id), &json!({ "enforcement_mode": "per_key", "key_cap": 50, "gb_per_key": 2 }).to_string(), ).await; assert_eq!(resp.status, 200, "activate failed: {}", resp.text); let body: BillingUpdatedResp = resp.json(); assert_eq!(body.monthly_price_cents, 300); let resp = h.client.get(&format!("/api/sync/apps/{}/billing", app_id)).await; let status: BillingStatusResp = resp.json(); assert_eq!(status.enforcement_mode, "per_key"); assert_eq!(status.storage_gb_cap, None); assert_eq!(status.key_cap, Some(50)); assert_eq!(status.gb_per_key, Some(2)); } #[tokio::test] async fn patch_reprices_subscription() { let mut h = TestHarness::with_mocks().await; let user_id = h.signup("dev3", "dev3@example.com", "Password1!").await; let (app_id, _) = create_draft_app(&h.db, user_id).await; h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await; h.client.post_json( &format!("/api/sync/apps/{}/billing/activate", app_id), &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(), ).await; // PATCH up to 1000 GB → $30.00. let resp = h.client.patch_json( &format!("/api/sync/apps/{}/billing", app_id), &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 1000 }).to_string(), ).await; assert_eq!(resp.status, 200, "patch failed: {}", resp.text); let body: BillingUpdatedResp = resp.json(); assert_eq!(body.monthly_price_cents, 3000); } #[tokio::test] async fn cancel_returns_no_content_and_marks_canceled() { let mut h = TestHarness::with_mocks().await; let user_id = h.signup("dev4", "dev4@example.com", "Password1!").await; let (app_id, _) = create_draft_app(&h.db, user_id).await; h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await; h.client.post_json( &format!("/api/sync/apps/{}/billing/activate", app_id), &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(), ).await; let resp = h.client.delete(&format!("/api/sync/apps/{}/billing", app_id)).await; assert_eq!(resp.status, 204, "cancel failed: {}", resp.text); let status: String = sqlx::query_scalar( "SELECT billing_status FROM sync_apps WHERE id = $1", ) .bind(app_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(status, "canceled"); } #[tokio::test] async fn activate_rejects_invalid_knobs() { let mut h = TestHarness::with_mocks().await; let user_id = h.signup("dev5", "dev5@example.com", "Password1!").await; let (app_id, _) = create_draft_app(&h.db, user_id).await; h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await; // per_key without key_cap → 400. let resp = h.client.post_json( &format!("/api/sync/apps/{}/billing/activate", app_id), &json!({ "enforcement_mode": "per_key", "gb_per_key": 1 }).to_string(), ).await; assert_eq!(resp.status, 400, "expected 400 for missing key_cap: {}", resp.text); // per_key without gb_per_key → 400. let resp = h.client.post_json( &format!("/api/sync/apps/{}/billing/activate", app_id), &json!({ "enforcement_mode": "per_key", "key_cap": 10 }).to_string(), ).await; assert_eq!(resp.status, 400, "expected 400 for missing gb_per_key: {}", resp.text); // bulk with storage_gb_cap = 0 → 400. let resp = h.client.post_json( &format!("/api/sync/apps/{}/billing/activate", app_id), &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 0 }).to_string(), ).await; assert_eq!(resp.status, 400, "expected 400 for zero storage_gb_cap: {}", resp.text); // bulk with extra knobs → 400. let resp = h.client.post_json( &format!("/api/sync/apps/{}/billing/activate", app_id), &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 10, "key_cap": 5 }).to_string(), ).await; assert_eq!(resp.status, 400, "expected 400 for mixing modes: {}", resp.text); } #[tokio::test] async fn claim_key_blocked_when_billing_inactive() { let mut h = TestHarness::with_mocks().await; let user_id = h.signup("dev6", "dev6@example.com", "Password1!").await; let (_, api_key) = create_draft_app(&h.db, user_id).await; // App is non-internal + draft → claim must return 402 billing_inactive. let resp = h.client.post_json( "/api/sync/keys/claim", &json!({ "api_key": api_key, "key": "dev-key-1" }).to_string(), ).await; assert_eq!(resp.status, 402, "expected 402, got {}: {}", resp.status, resp.text); assert!(resp.text.contains("billing_inactive"), "got body: {}", resp.text); } #[tokio::test] async fn claim_key_blocked_at_cap_in_per_key_mode() { let mut h = TestHarness::with_mocks().await; let user_id = h.signup("dev7", "dev7@example.com", "Password1!").await; let (app_id, api_key) = create_draft_app(&h.db, user_id).await; // Activate in per_key mode with a tiny cap so we can exhaust it cheaply. h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await; let resp = h.client.post_json( &format!("/api/sync/apps/{}/billing/activate", app_id), &json!({ "enforcement_mode": "per_key", "key_cap": 2, "gb_per_key": 1 }).to_string(), ).await; assert_eq!(resp.status, 200, "activate failed: {}", resp.text); // First two claims succeed. for k in &["k1", "k2"] { let resp = h.client.post_json( "/api/sync/keys/claim", &json!({ "api_key": api_key, "key": k }).to_string(), ).await; assert_eq!(resp.status, 200, "claim {} failed: {}", k, resp.text); } // Third claim hits the cap → 402. let resp = h.client.post_json( "/api/sync/keys/claim", &json!({ "api_key": api_key, "key": "k3" }).to_string(), ).await; assert_eq!(resp.status, 402, "expected 402, got {}: {}", resp.status, resp.text); assert!(resp.text.contains("key_limit_reached"), "got body: {}", resp.text); // Re-claiming an already-active key is idempotent (does not consume a slot). let resp = h.client.post_json( "/api/sync/keys/claim", &json!({ "api_key": api_key, "key": "k1" }).to_string(), ).await; assert_eq!(resp.status, 200, "re-claim should succeed: {}", resp.text); } // ── Edge cases (test-fuzz) ── #[tokio::test] async fn cannot_activate_after_cancel() { let mut h = TestHarness::with_mocks().await; let user_id = h.signup("cancel1", "cancel1@example.com", "Password1!").await; let (app_id, _) = create_draft_app(&h.db, user_id).await; h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await; h.client.post_json( &format!("/api/sync/apps/{}/billing/activate", app_id), &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(), ).await; let resp = h.client.delete(&format!("/api/sync/apps/{}/billing", app_id)).await; assert_eq!(resp.status, 204); // Activate now requires draft status — canceled apps cannot be reactivated. let resp = h.client.post_json( &format!("/api/sync/apps/{}/billing/activate", app_id), &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(), ).await; assert_eq!(resp.status, 409, "expected 409 conflict on re-activate after cancel: {}", resp.text); } #[tokio::test] async fn cancel_is_idempotent() { let mut h = TestHarness::with_mocks().await; let user_id = h.signup("can2", "can2@example.com", "Password1!").await; let (app_id, _) = create_draft_app(&h.db, user_id).await; h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await; h.client.post_json( &format!("/api/sync/apps/{}/billing/activate", app_id), &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 10 }).to_string(), ).await; let r1 = h.client.delete(&format!("/api/sync/apps/{}/billing", app_id)).await; assert_eq!(r1.status, 204); let r2 = h.client.delete(&format!("/api/sync/apps/{}/billing", app_id)).await; assert_eq!(r2.status, 204, "second cancel must also be 204, got {}", r2.status); } #[tokio::test] async fn patch_switches_mode_bulk_to_per_key() { // Activating in bulk and then PATCHing to per_key must succeed and update // the columns coherently (bulk knob cleared, per_key knobs set). let mut h = TestHarness::with_mocks().await; let user_id = h.signup("mode1", "mode1@example.com", "Password1!").await; let (app_id, _) = create_draft_app(&h.db, user_id).await; h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await; h.client.post_json( &format!("/api/sync/apps/{}/billing/activate", app_id), &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(), ).await; let resp = h.client.patch_json( &format!("/api/sync/apps/{}/billing", app_id), &json!({ "enforcement_mode": "per_key", "key_cap": 5, "gb_per_key": 20 }).to_string(), ).await; assert_eq!(resp.status, 200, "mode switch failed: {}", resp.text); let body: BillingUpdatedResp = resp.json(); // 5 × 20 = 100 GB equivalent → 300 cents, same as the bulk price before. assert_eq!(body.monthly_price_cents, 300); // Verify the row reflects the switch: bulk knob cleared, per_key knobs set. let resp = h.client.get(&format!("/api/sync/apps/{}/billing", app_id)).await; let status: BillingStatusResp = resp.json(); assert_eq!(status.enforcement_mode, "per_key"); assert_eq!(status.storage_gb_cap, None, "bulk knob should be cleared on mode switch"); assert_eq!(status.key_cap, Some(5)); assert_eq!(status.gb_per_key, Some(20)); } #[tokio::test] async fn setup_rejects_non_draft_app() { // Once activated, calling setup again must 409 (not silently re-mint // a customer). let mut h = TestHarness::with_mocks().await; let user_id = h.signup("setup2", "setup2@example.com", "Password1!").await; let (app_id, _) = create_draft_app(&h.db, user_id).await; h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await; h.client.post_json( &format!("/api/sync/apps/{}/billing/activate", app_id), &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 10 }).to_string(), ).await; let resp = h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await; assert_eq!(resp.status, 409, "second setup on active app must 409, got {}: {}", resp.status, resp.text); } #[tokio::test] async fn other_users_app_billing_rejected() { // Cross-tenant: dev A cannot inspect or mutate dev B's app billing. let mut h = TestHarness::with_mocks().await; let owner = h.signup("owner1", "owner1@example.com", "Password1!").await; let (app_id, _) = create_draft_app(&h.db, owner).await; // Switch session to a second user. let _other = h.signup("other1", "other1@example.com", "Password1!").await; // signup auto-logs-in the new user; the previous session is replaced. let r = h.client.get(&format!("/api/sync/apps/{}/billing", app_id)).await; assert_eq!(r.status, 403, "expected 403 forbidden cross-tenant: {}", r.text); let r = h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await; assert_eq!(r.status, 403); let r = h.client.delete(&format!("/api/sync/apps/{}/billing", app_id)).await; assert_eq!(r.status, 403); } /// A canceled SyncKit app must not be reactivated by an out-of-order /// `customer.subscription.updated`(active) — the second Run #12 SERIOUS revival /// path. The terminal guard now lives in `set_billing_status`. #[tokio::test] async fn webhook_subscription_updated_cannot_revive_canceled_synckit_billing() { let mut h = TestHarness::with_mocks().await; let user_id = h.signup("sktrevive", "sktrevive@test.com", "password123").await; let (app_id, _) = create_draft_app(&h.db, user_id).await; let stripe_sub_id = "sub_skt_revive_1"; sqlx::query("UPDATE sync_apps SET stripe_subscription_id = $1, billing_status = 'canceled' WHERE id = $2") .bind(stripe_sub_id) .bind(app_id) .execute(&h.db) .await .unwrap(); let sub = json!({ "id": stripe_sub_id, "object": "subscription", "status": "active", "cancel_at_period_end": false, "items": {"object": "list", "data": [{ "id": "si_skt_revive", "current_period_start": 1700000000_i64, "current_period_end": 1702592000_i64, }]}, }); let payload = json!({ "id": "evt_skt_revive", "type": "customer.subscription.updated", "data": {"object": sub}, }) .to_string(); let sig = crate::harness::stripe::sign_webhook_payload(&payload, crate::harness::stripe::TEST_WEBHOOK_SECRET); let resp = h .client .request_with_headers( "POST", "/stripe/webhook", Some(&payload), &[("stripe-signature", &sig), ("content-type", "application/json")], ) .await; assert_eq!(resp.status.as_u16(), 200, "webhook must not error: {}", resp.text); let status: String = sqlx::query_scalar("SELECT billing_status FROM sync_apps WHERE id = $1") .bind(app_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(status, "canceled", "a canceled SyncKit app must not be reactivated by an out-of-order update"); } /// A canceled SyncKit app's PERIOD (and usage) must not be refreshed by a stray /// `invoice.payment_succeeded`. The period write used to live in an unguarded /// `set_period`; it now shares `apply_billing_update`'s terminal-canceled guard, /// and the usage reset is gated on that write succeeding. (Run #13 chronic fix.) #[tokio::test] async fn webhook_invoice_paid_cannot_refresh_period_on_canceled_synckit_app() { let mut h = TestHarness::with_mocks().await; let user_id = h.signup("sktperiod", "sktperiod@test.com", "password123").await; let (app_id, _) = create_draft_app(&h.db, user_id).await; let stripe_sub_id = "sub_skt_period_1"; sqlx::query( "UPDATE sync_apps SET stripe_subscription_id = $1, billing_status = 'canceled', \ current_period_end = to_timestamp(1000000000) WHERE id = $2", ) .bind(stripe_sub_id) .bind(app_id) .execute(&h.db) .await .unwrap(); let invoice = json!({ "id": "in_skt_period", "object": "invoice", "subscription": stripe_sub_id, "billing_reason": "subscription_cycle", "period_start": 1700000000_i64, "period_end": 1702592000_i64, "currency": "usd", "livemode": false, }); let payload = json!({ "id": "evt_skt_period", "type": "invoice.payment_succeeded", "data": {"object": invoice}, }) .to_string(); let sig = crate::harness::stripe::sign_webhook_payload(&payload, crate::harness::stripe::TEST_WEBHOOK_SECRET); let resp = h .client .request_with_headers( "POST", "/stripe/webhook", Some(&payload), &[("stripe-signature", &sig), ("content-type", "application/json")], ) .await; assert_eq!(resp.status.as_u16(), 200, "webhook must not error: {}", resp.text); let (status, period_end): (String, chrono::DateTime) = sqlx::query_as( "SELECT billing_status, current_period_end FROM sync_apps WHERE id = $1", ) .bind(app_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(status, "canceled", "canceled app must stay canceled"); assert_eq!(period_end.timestamp(), 1000000000, "period must stay frozen on a canceled app, not jump to the invoice's period_end"); }