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
@@ -26,6 +26,32 @@ impl Tier {
}
}
/// Pro-and-above unlock the animated layer types (PRD §6.1.1, §19.1):
/// video background, particle systems, animated (shader) gradients.
pub fn allows_pro_layers(self) -> bool {
!matches!(self, Tier::Free)
}
/// Geographic analytics are Pro-and-above (PRD §19.1).
pub fn allows_geo_analytics(self) -> bool {
!matches!(self, Tier::Free)
}
/// Custom domains are Pro-and-above (PRD §19.1).
pub fn allows_custom_domain(self) -> bool {
!matches!(self, Tier::Free)
}
/// Analytics retention window in days; `None` = unlimited (PRD §19.1).
pub fn analytics_retention_days(self) -> Option<i64> {
match self {
Tier::Free => Some(7),
Tier::Pro => Some(90),
Tier::Team => Some(365),
Tier::Enterprise => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Tier::Free => "free",
@@ -36,6 +62,28 @@ impl Tier {
}
}
#[cfg(test)]
mod tests {
use super::Tier;
#[test]
fn capability_matrix() {
assert_eq!(Tier::Free.active_card_limit(), Some(1));
assert_eq!(Tier::Pro.active_card_limit(), Some(5));
assert_eq!(Tier::Team.active_card_limit(), None);
assert!(!Tier::Free.allows_pro_layers());
assert!(Tier::Pro.allows_pro_layers());
assert!(!Tier::Free.allows_geo_analytics());
assert!(Tier::Enterprise.allows_geo_analytics());
assert_eq!(Tier::Free.analytics_retention_days(), Some(7));
assert_eq!(Tier::Pro.analytics_retention_days(), Some(90));
assert_eq!(Tier::Enterprise.analytics_retention_days(), None);
}
}
impl std::str::FromStr for Tier {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {