//! SyncKit developer billing: pricing formula and constants. //! //! Two modes: //! //! bulk, price = storage_gb_cap × $0.03 //! per_key, price = key_cap × gb_per_key × $0.03 //! //! Both are pure GB-based pricing. Egress and ingress are absorbed by the //! storage rate's margin against Hetzner Object Storage (~$0.0065/GB), where //! SyncKit blobs are hosted in the `synckit` bucket. //! //! Invoices are floored at a Stripe-fee-cover threshold so we never lose money //! on a transaction. See `BASE_FLOOR_CENTS` for the math. /// Storage rate in cents per GB per month. Runs ~4.6× the Hetzner Object /// Storage cost behind it (€5.99/TB/month, ~$0.0065/GB at 1.09 USD/EUR, with /// 1 TB of egress included per TB stored). That spread absorbs any /// ingress/egress variance, so we don't need a separate egress price. The rate /// was calibrated against Cloudflare R2 and documented as ~2× cost until /// 2026-07-27; the blobs were never on R2, so the basis was wrong, not the /// price. See `mnw-biz-synckit-pricing` in the wiki. pub const STORAGE_RATE_CENTS_PER_GB: i64 = 3; /// Upper bound on a developer-billing storage cap, in GB. Applies to the bulk /// `storage_gb_cap` and to the `key_cap × gb_per_key` product in per_key mode. /// 10 TiB, matches the end-user `MAX_CAP_BYTES` and the picker slider's `max`. /// Without it, `validate_knobs` accepted any value `> 0`, so a developer could /// provision a Stripe subscription priced in the billions per month. pub const MAX_STORAGE_GB: i64 = 10 * 1024; /// Stripe-fee-cover floor in cents. Stripe charges 2.9% + $0.30 per /// successful charge. We pick the smallest invoice `F` (cents) such that the /// remainder after Stripe fees is non-negative: /// /// F × (1 − 0.029) − 30 ≥ 0 ⇒ F ≥ 30 / 0.971 ⇒ F ≥ 30.9¢ /// /// Round up to 31¢. At the floor, MNW nets ~$0, covered, not profitable. pub const BASE_FLOOR_CENTS: i64 = 31; /// Warning thresholds (percent of storage cap). Matches CHECK constraint on /// `sync_app_usage_current.last_warning_pct`. Only storage is enforced, so /// these thresholds apply to storage usage only. pub const WARNING_THRESHOLDS_PCT: &[i16] = &[75, 90, 100]; /// Compute the monthly Stripe invoice amount in cents for a given knob set. /// /// In bulk mode: `storage_gb_cap` is set, others are `None`. /// In per_key mode: `key_cap` and `gb_per_key` are set, `storage_gb_cap` is `None`. /// /// Floors at `BASE_FLOOR_CENTS` so we never invoice below the Stripe-fee /// break-even amount. pub fn monthly_price_cents( enforcement_mode: crate::db::SyncEnforcementMode, storage_gb_cap: Option, key_cap: Option, gb_per_key: Option, ) -> i64 { use crate::db::SyncEnforcementMode::{Bulk, PerKey}; // Pure integer-cents arithmetic. The rate is a whole number of cents and // the caps are whole GB, so there is no fractional money to round; the old // `(gb as f64 * 3.0).ceil()` was an unnecessary trip through f64. Saturating // multiplies keep absurd admin-set caps from overflowing i64 instead of // wrapping to a negative invoice. The match is exhaustive over the sealed // enum, there is no unknown-mode arm that could silently price at the floor // (Pay-S2); an invalid mode can't reach here because the column is // CHECK-constrained and the type is parsed at the API boundary. let gb: i64 = match enforcement_mode { Bulk => i64::from(storage_gb_cap.unwrap_or(0)), PerKey => { let k = i64::from(key_cap.unwrap_or(0)); let g = i64::from(gb_per_key.unwrap_or(0)); k.saturating_mul(g) } }; let raw = gb.saturating_mul(STORAGE_RATE_CENTS_PER_GB); raw.max(BASE_FLOOR_CENTS) } /// Storage cap in bytes for the given GB cap. pub fn storage_cap_bytes(storage_gb: u32) -> i64 { i64::from(storage_gb) * 1024 * 1024 * 1024 } #[cfg(test)] mod tests { use super::*; use crate::db::SyncEnforcementMode::{Bulk, PerKey}; #[test] fn bulk_mode_pricing() { // 100 GB bulk → 100 × 3 = 300 cents. assert_eq!(monthly_price_cents(Bulk, Some(100), None, None), 300); // 1000 GB → $30. assert_eq!(monthly_price_cents(Bulk, Some(1000), None, None), 3000); } #[test] fn per_key_mode_pricing() { // 50 keys × 2 GB = 100 GB equivalent → 300 cents. Matches 100 GB bulk. assert_eq!(monthly_price_cents(PerKey, None, Some(50), Some(2)), 300); // 1000 keys × 1 GB → $30. assert_eq!(monthly_price_cents(PerKey, None, Some(1000), Some(1)), 3000); } #[test] fn floor_kicks_in_for_small_accounts() { // 1 GB bulk → 3¢ raw, floored to 31¢. assert_eq!(monthly_price_cents(Bulk, Some(1), None, None), 31); // 10 GB → 30¢, also floored to 31¢ (one cent short). assert_eq!(monthly_price_cents(Bulk, Some(10), None, None), 31); // 11 GB → 33¢, above floor. assert_eq!(monthly_price_cents(Bulk, Some(11), None, None), 33); // 1 key × 1 GB → 3¢ raw, floored. assert_eq!(monthly_price_cents(PerKey, None, Some(1), Some(1)), 31); } #[test] fn heavy_workload_pricing() { // 10 TB bulk → 10240 × 3 = 30720¢ = $307.20. assert_eq!(monthly_price_cents(Bulk, Some(10_240), None, None), 30_720); // 10k keys × 1 GB → same. assert_eq!( monthly_price_cents(PerKey, None, Some(10_000), Some(1)), 30_000 ); } #[test] fn missing_knobs_drop_to_floor() { // Mode is set but no knobs provided, should hit the floor. assert_eq!( monthly_price_cents(Bulk, None, None, None), BASE_FLOOR_CENTS ); assert_eq!( monthly_price_cents(PerKey, None, None, None), BASE_FLOOR_CENTS ); } // (The former `unknown_mode_drops_to_floor` test is gone: `enforcement_mode` // is now a sealed enum, so an unrecognized mode is unrepresentable, the // Pay-S2 fail-open it guarded against can no longer be written.) #[test] fn floor_amount_covers_stripe_fee() { // 31¢ × 0.971 = 30.10¢, minus 30¢ fixed fee = 0.10¢ net. Verifies the // documented math: the floor covers Stripe's fee with ~0 margin. let net = (BASE_FLOOR_CENTS as f64) * 0.971 - 30.0; assert!( net >= 0.0, "floor must net ≥ 0 after Stripe fees, got {net}" ); assert!(net < 1.0, "floor should be tight, not overshoot, got {net}"); } #[test] fn storage_cap_in_bytes() { assert_eq!(storage_cap_bytes(10), 10 * 1024 * 1024 * 1024); } // ── Edge cases (test-fuzz) ── #[test] fn pricing_at_u32_max_does_not_panic() { // u32::MAX GB × 3¢ ≈ 1.3e10 cents, fits in i64. The cast must not panic. let p = monthly_price_cents(Bulk, Some(u32::MAX), None, None); assert!(p > 0, "huge price should be positive, got {p}"); } #[test] fn per_key_pricing_at_u32_max_saturates_cleanly() { // u32::MAX × u32::MAX overflows f64 precision but Rust's f64-as-i64 cast // saturates at i64::MAX rather than UB. Must not panic. let p = monthly_price_cents(PerKey, None, Some(u32::MAX), Some(u32::MAX)); assert!(p > 0, "saturated price should still be positive, got {p}"); } #[test] fn storage_cap_at_u32_max_fits_in_i64() { // u32::MAX × 2^30 = ~4.6e18, well under i64::MAX (~9.2e18). let bytes = storage_cap_bytes(u32::MAX); assert!(bytes > 0, "u32::MAX GB should produce a positive i64"); assert_eq!(bytes, (u32::MAX as i64) * 1024 * 1024 * 1024); } #[test] fn bulk_with_zero_gb_drops_to_floor() { // Defensive: validate_knobs rejects gb=0 at the route layer, but the // pure function should still produce the floor rather than 0. assert_eq!( monthly_price_cents(Bulk, Some(0), None, None), BASE_FLOOR_CENTS ); } #[test] fn per_key_one_dimension_zero_drops_to_floor() { // If only one of key_cap/gb_per_key is 0, the product is 0 → floor. assert_eq!( monthly_price_cents(PerKey, None, Some(0), Some(10)), BASE_FLOOR_CENTS ); assert_eq!( monthly_price_cents(PerKey, None, Some(10), Some(0)), BASE_FLOOR_CENTS ); } }