Phase 3 backend: tier-gate enforcement + billing webhook
- 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:
co-authored by
Claude Opus 4.8
parent
9b7c845319
commit
4ad3220917
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user