max / makenotwork
5 files changed,
+126 insertions,
-11 deletions
| @@ -1022,6 +1022,7 @@ impl_str_enum!(ImportJobStatus { | |||
| 1022 | 1022 | // -- Moderation action types -------------------------------------------------- | |
| 1023 | 1023 | ||
| 1024 | 1024 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 1025 | + | #[serde(rename_all = "snake_case")] | |
| 1025 | 1026 | pub enum ModerationActionType { | |
| 1026 | 1027 | Warning, | |
| 1027 | 1028 | Suspension, | |
| @@ -1040,6 +1041,7 @@ impl_str_enum!(ModerationActionType { | |||
| 1040 | 1041 | ||
| 1041 | 1042 | /// Discriminator for checkout session types stored in Stripe metadata. | |
| 1042 | 1043 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 1044 | + | #[serde(rename_all = "snake_case")] | |
| 1043 | 1045 | pub enum CheckoutType { | |
| 1044 | 1046 | Guest, | |
| 1045 | 1047 | Subscription, | |
| @@ -1605,4 +1607,81 @@ mod tests { | |||
| 1605 | 1607 | assert_eq!("content_removal".parse::<ModerationActionType>().unwrap(), ModerationActionType::ContentRemoval); | |
| 1606 | 1608 | assert!("bogus".parse::<ModerationActionType>().is_err()); | |
| 1607 | 1609 | } | |
| 1610 | + | ||
| 1611 | + | /// Every `impl_str_enum!` enum that also derives serde must agree on its wire | |
| 1612 | + | /// string across BOTH representations: the macro's Display/FromStr/sqlx form | |
| 1613 | + | /// (`VARIANTS`) and serde's JSON form. These are defined in two places (the | |
| 1614 | + | /// macro literal and the `#[serde(rename_all = ...)]` attribute), so they can | |
| 1615 | + | /// silently diverge — a variant travels via Display today and via serde | |
| 1616 | + | /// tomorrow, and the string disagrees with what is already in Postgres. This | |
| 1617 | + | /// test drives every serde-deriving enum through its `VARIANTS` (which round | |
| 1618 | + | /// through `FromStr`) and asserts serde emits the identical string, in both | |
| 1619 | + | /// directions. Add every serde-deriving enum here. | |
| 1620 | + | macro_rules! assert_serde_matches_wire { | |
| 1621 | + | ($($enum:ty),+ $(,)?) => {{ | |
| 1622 | + | $( | |
| 1623 | + | for wire in <$enum>::VARIANTS { | |
| 1624 | + | let value: $enum = wire.parse().unwrap_or_else(|e| { | |
| 1625 | + | panic!("{}: VARIANTS string {wire:?} does not parse: {e}", stringify!($enum)) | |
| 1626 | + | }); | |
| 1627 | + | let json = serde_json::to_string(&value).expect("serialize"); | |
| 1628 | + | assert_eq!( | |
| 1629 | + | json, | |
| 1630 | + | format!("\"{wire}\""), | |
| 1631 | + | "{}: serde emits {json} but the DB/Display wire string is {wire:?} \ | |
| 1632 | + | (add or fix `#[serde(rename_all = ...)]`)", | |
| 1633 | + | stringify!($enum) | |
| 1634 | + | ); | |
| 1635 | + | let back: $enum = serde_json::from_str(&json).unwrap_or_else(|e| { | |
| 1636 | + | panic!("{}: serde cannot round-trip {json}: {e}", stringify!($enum)) | |
| 1637 | + | }); | |
| 1638 | + | assert_eq!( | |
| 1639 | + | back.to_string(), | |
| 1640 | + | *wire, | |
| 1641 | + | "{}: serde deserialize of {json} disagrees with Display", | |
| 1642 | + | stringify!($enum) | |
| 1643 | + | ); | |
| 1644 | + | } | |
| 1645 | + | )+ | |
| 1646 | + | }}; | |
| 1647 | + | } | |
| 1648 | + | ||
| 1649 | + | #[test] | |
| 1650 | + | fn serde_repr_matches_db_wire_repr_for_every_enum() { | |
| 1651 | + | assert_serde_matches_wire!( | |
| 1652 | + | DiscountType, | |
| 1653 | + | CodePurpose, | |
| 1654 | + | WaitlistStatus, | |
| 1655 | + | SelectionMethod, | |
| 1656 | + | TransactionStatus, | |
| 1657 | + | FollowTargetType, | |
| 1658 | + | SubscriptionStatus, | |
| 1659 | + | SyncBillingStatus, | |
| 1660 | + | SyncEnforcementMode, | |
| 1661 | + | Visibility, | |
| 1662 | + | ProjectRole, | |
| 1663 | + | SyncOperation, | |
| 1664 | + | SyncPlatform, | |
| 1665 | + | FileScanStatus, | |
| 1666 | + | InsertionPosition, | |
| 1667 | + | AppealDecision, | |
| 1668 | + | DiscoverSort, | |
| 1669 | + | ItemType, | |
| 1670 | + | IssueStatus, | |
| 1671 | + | ReportTargetType, | |
| 1672 | + | ReportType, | |
| 1673 | + | ReportStatus, | |
| 1674 | + | CreatorTier, | |
| 1675 | + | AiTier, | |
| 1676 | + | ProjectFeature, | |
| 1677 | + | ProjectType, | |
| 1678 | + | BuildStatus, | |
| 1679 | + | PricingKind, | |
| 1680 | + | MailingListType, | |
| 1681 | + | ImportSource, | |
| 1682 | + | ImportJobStatus, | |
| 1683 | + | ModerationActionType, | |
| 1684 | + | CheckoutType, | |
| 1685 | + | ); | |
| 1686 | + | } | |
| 1608 | 1687 | } |
| @@ -103,7 +103,6 @@ pub struct SynckitAppSubCheckoutParams<'a> { | |||
| 103 | 103 | pub interval: &'a str, | |
| 104 | 104 | pub user_id: UserId, | |
| 105 | 105 | pub app_id: SyncAppId, | |
| 106 | - | pub tier: &'a str, | |
| 107 | 106 | pub storage_limit_bytes: Option<i64>, | |
| 108 | 107 | pub success_url: &'a str, | |
| 109 | 108 | pub cancel_url: &'a str, | |
| @@ -459,7 +458,7 @@ impl StripeClient { | |||
| 459 | 458 | metadata.insert("checkout_type".to_string(), CheckoutType::SynckitAppSub.to_string()); | |
| 460 | 459 | metadata.insert("user_id".to_string(), p.user_id.to_string()); | |
| 461 | 460 | metadata.insert("app_id".to_string(), p.app_id.to_string()); | |
| 462 | - | metadata.insert("tier".to_string(), p.tier.to_string()); | |
| 461 | + | metadata.insert("interval".to_string(), p.interval.to_string()); | |
| 463 | 462 | if let Some(bytes) = p.storage_limit_bytes { | |
| 464 | 463 | metadata.insert("storage_limit_bytes".to_string(), bytes.to_string()); | |
| 465 | 464 | } |
| @@ -180,12 +180,14 @@ pub fn is_synckit_app_sub_checkout(meta: Option<&CheckoutMetaMap>) -> bool { | |||
| 180 | 180 | get_checkout_type(meta) == Some(CheckoutType::SynckitAppSub) | |
| 181 | 181 | } | |
| 182 | 182 | ||
| 183 | - | /// Parsed metadata for an end-user subscribing to an app's cloud sync. | |
| 183 | + | /// Parsed metadata for an end-user subscribing to an app's cloud sync. The | |
| 184 | + | /// billing cadence is carried as `interval` ("monthly"/"annual"); a SyncKit app | |
| 185 | + | /// sub has no creator-tier concept. | |
| 184 | 186 | #[derive(Debug)] | |
| 185 | 187 | pub struct SynckitAppSubCheckoutMetadata { | |
| 186 | 188 | pub user_id: UserId, | |
| 187 | 189 | pub app_id: SyncAppId, | |
| 188 | - | pub tier: String, | |
| 190 | + | pub interval: String, | |
| 189 | 191 | pub storage_limit_bytes: Option<i64>, | |
| 190 | 192 | } | |
| 191 | 193 | ||
| @@ -193,11 +195,18 @@ impl SynckitAppSubCheckoutMetadata { | |||
| 193 | 195 | pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result<Self> { | |
| 194 | 196 | let user_id: UserId = parse_uuid_to(require(meta, "user_id")?, "user_id")?; | |
| 195 | 197 | let app_id: SyncAppId = parse_uuid_to(require(meta, "app_id")?, "app_id")?; | |
| 196 | - | let tier = require(meta, "tier")?.clone(); | |
| 198 | + | // Read the billing cadence from `interval`, falling back to the legacy | |
| 199 | + | // `tier` key that older checkout sessions (created before this key was | |
| 200 | + | // corrected) still carry — so a session in flight across the deploy | |
| 201 | + | // still settles. | |
| 202 | + | let interval = match meta.and_then(|m| m.get("interval")) { | |
| 203 | + | Some(v) => v.clone(), | |
| 204 | + | None => require(meta, "tier")?.clone(), | |
| 205 | + | }; | |
| 197 | 206 | let storage_limit_bytes = meta | |
| 198 | 207 | .and_then(|m| m.get("storage_limit_bytes")) | |
| 199 | 208 | .and_then(|v| v.parse::<i64>().ok()); | |
| 200 | - | Ok(SynckitAppSubCheckoutMetadata { user_id, app_id, tier, storage_limit_bytes }) | |
| 209 | + | Ok(SynckitAppSubCheckoutMetadata { user_id, app_id, interval, storage_limit_bytes }) | |
| 201 | 210 | } | |
| 202 | 211 | } | |
| 203 | 212 | ||
| @@ -209,6 +218,38 @@ mod tests { | |||
| 209 | 218 | entries.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() | |
| 210 | 219 | } | |
| 211 | 220 | ||
| 221 | + | // --- SynckitAppSubCheckoutMetadata: interval key + legacy `tier` fallback --- | |
| 222 | + | ||
| 223 | + | #[test] | |
| 224 | + | fn synckit_app_sub_reads_interval_key() { | |
| 225 | + | let user = UserId::new(); | |
| 226 | + | let app = SyncAppId::new(); | |
| 227 | + | let m = meta_of(&[ | |
| 228 | + | ("user_id", &user.to_string()), | |
| 229 | + | ("app_id", &app.to_string()), | |
| 230 | + | ("interval", "annual"), | |
| 231 | + | ("storage_limit_bytes", "1073741824"), | |
| 232 | + | ]); | |
| 233 | + | let parsed = SynckitAppSubCheckoutMetadata::from_metadata(Some(&m)).unwrap(); | |
| 234 | + | assert_eq!(parsed.interval, "annual"); | |
| 235 | + | assert_eq!(parsed.storage_limit_bytes, Some(1073741824)); | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | #[test] | |
| 239 | + | fn synckit_app_sub_falls_back_to_legacy_tier_key() { | |
| 240 | + | // Sessions created before the key was corrected carry the cadence under | |
| 241 | + | // `tier`; those must still settle after the deploy. | |
| 242 | + | let user = UserId::new(); | |
| 243 | + | let app = SyncAppId::new(); | |
| 244 | + | let m = meta_of(&[ | |
| 245 | + | ("user_id", &user.to_string()), | |
| 246 | + | ("app_id", &app.to_string()), | |
| 247 | + | ("tier", "monthly"), | |
| 248 | + | ]); | |
| 249 | + | let parsed = SynckitAppSubCheckoutMetadata::from_metadata(Some(&m)).unwrap(); | |
| 250 | + | assert_eq!(parsed.interval, "monthly"); | |
| 251 | + | } | |
| 252 | + | ||
| 212 | 253 | // --- CheckoutMetadata --- | |
| 213 | 254 | ||
| 214 | 255 | #[test] |
| @@ -777,7 +777,7 @@ pub(super) async fn handle_synckit_app_sub_checkout_completed( | |||
| 777 | 777 | app_id: meta.app_id, | |
| 778 | 778 | stripe_subscription_id: &stripe_subscription_id, | |
| 779 | 779 | stripe_customer_id: &stripe_customer_id, | |
| 780 | - | interval: &meta.tier, // metadata "tier" carries the interval string ("monthly"/"annual") | |
| 780 | + | interval: &meta.interval, | |
| 781 | 781 | storage_limit_bytes: meta.storage_limit_bytes.unwrap_or(0), | |
| 782 | 782 | }, | |
| 783 | 783 | ) |
| @@ -380,10 +380,6 @@ pub(super) async fn create_subscription_checkout( | |||
| 380 | 380 | interval: interval.as_str(), | |
| 381 | 381 | user_id: sync_user.user_id, | |
| 382 | 382 | app_id: sync_user.app_id, | |
| 383 | - | // NOTE: `tier` carries the interval string here, matching the prior | |
| 384 | - | // positional call. Looks suspect (tier vs interval) but preserved | |
| 385 | - | // verbatim by this refactor — review separately, don't silently change. | |
| 386 | - | tier: interval.as_str(), | |
| 387 | 383 | storage_limit_bytes: Some(req.cap_bytes), | |
| 388 | 384 | success_url: &success_url, | |
| 389 | 385 | cancel_url: &cancel_url, |