Phase 3 backend: tier-gate enforcement + billing webhook
CI / policy (push) Successful in 5s
CI / profile (push) Successful in 11s
CI / mobile (push) Successful in 27s
CI / backend (push) Failing after 1m7s

- Tier capability model (pro-layers, geo-analytics, custom-domain, retention)
- Pro-only layer types (video/particle/animatedGradient) rejected on card
  create/replace/patch for Free; geo analytics gated to Pro+ (402 tier_limit)
- Gates read the authoritative DB tier, so upgrades apply without re-login
- POST /v1/webhooks/revenuecat: shared-secret auth (constant-time), maps
  RevenueCat events to users.tier (purchase→pro/team/enterprise, cancel→free),
  unknown user = 2xx no-op; users::update_tier query

93 backend tests; fmt + clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-04 13:55:21 -05:00
co-authored by Claude Opus 4.8
parent 9b7c845319
commit 4ad3220917
17 changed files with 518 additions and 4 deletions
@@ -89,6 +89,7 @@ pub async fn try_setup() -> Option<TestApp> {
apple_audience: "com.cardclaws.test".into(),
profile_base_url: "https://cardclaws.test".into(),
ip_hash_secret: "test-ip-salt".into(),
billing_webhook_secret: "test-webhook-secret".into(),
wallet: WalletConfig {
apple_pass_type_id: "pass.com.cardclaws.test".into(),
apple_team_id: "TEST123".into(),
@@ -283,6 +284,49 @@ impl TestApp {
(resp.status(), location)
}
/// Register a fresh user; return its access token and user id.
pub async fn register_and_user(&self) -> (String, String) {
let (email, handle) = unique_identity();
let (status, body) = self
.post(
"/v1/auth/register",
serde_json::json!({
"email": email,
"password": "correct horse battery",
"handle": handle,
"display_name": "Tier User",
}),
)
.await;
assert_eq!(status, StatusCode::OK, "registration failed: {body}");
(
body["access_token"].as_str().unwrap().to_string(),
body["user"]["id"].as_str().unwrap().to_string(),
)
}
/// Read a user's current tier from the DB.
pub async fn get_tier(&self, user_id: &str) -> String {
let id = uuid::Uuid::parse_str(user_id).unwrap();
let (tier,): (String,) = sqlx::query_as("SELECT tier FROM users WHERE id = $1")
.bind(id)
.fetch_one(&self.db)
.await
.unwrap();
tier
}
/// Force a user's tier directly in the DB (simulates a billing webhook).
pub async fn set_tier(&self, user_id: &str, tier: &str) {
let id = uuid::Uuid::parse_str(user_id).unwrap();
sqlx::query("UPDATE users SET tier = $1 WHERE id = $2")
.bind(tier)
.bind(id)
.execute(&self.db)
.await
.unwrap();
}
/// Register a fresh user and return its access token.
pub async fn register_and_token(&self) -> String {
let (email, handle) = unique_identity();