max / makenotwork
6 files changed,
+81 insertions,
-82 deletions
| @@ -3551,7 +3551,7 @@ dependencies = [ | |||
| 3551 | 3551 | ||
| 3552 | 3552 | [[package]] | |
| 3553 | 3553 | name = "makenotwork" | |
| 3554 | - | version = "0.7.0" | |
| 3554 | + | version = "0.7.1" | |
| 3555 | 3555 | dependencies = [ | |
| 3556 | 3556 | "anyhow", | |
| 3557 | 3557 | "argon2", |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.7.0" | |
| 3 | + | version = "0.7.1" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "LICENSE" | |
| 6 | 6 |
| @@ -1,27 +1,25 @@ | |||
| 1 | 1 | //! SyncKit developer billing: pricing formula and constants. | |
| 2 | 2 | //! | |
| 3 | - | //! See migration 117 for the full pricing model. Summary: | |
| 3 | + | //! Canonical reference: `_private/docs/mnw/server-internal/business/synckit_pricing.md`. | |
| 4 | 4 | //! | |
| 5 | - | //! price = max($5 floor, storage_gb × $0.013 | |
| 6 | - | //! + storage_gb × egress_multiple × $0.0022 | |
| 7 | - | //! + key_cap × $0.02 (per_key mode only)) | |
| 5 | + | //! monthly_price = storage_gb × $0.15 | |
| 6 | + | //! + storage_gb × egress_multiple × $0.03 | |
| 8 | 7 | //! | |
| 9 | - | //! Constants live here, not in the DB — tuning them is a code change, not a | |
| 10 | - | //! migration. Costs are pinned to Hetzner Object Storage marginal rates at | |
| 11 | - | //! roughly 2× margin (see CLAUDE memory `project_synckit_pricing.md` if/when | |
| 12 | - | //! it gets written). | |
| 13 | - | ||
| 14 | - | /// Pro-rated minimum monthly charge per app, in cents. | |
| 15 | - | pub const BASE_FLOOR_CENTS: i64 = 500; | |
| 16 | - | ||
| 17 | - | /// Storage rate in cents per GB per month (2× Hetzner Object Storage cost). | |
| 18 | - | pub const STORAGE_RATE_CENTS_PER_GB: f64 = 1.3; | |
| 8 | + | //! Constants are pinned to ~95% margin against Hetzner Object Storage so the | |
| 9 | + | //! pricing survives a move to a more expensive backend (AWS S3, R2) without a | |
| 10 | + | //! customer-facing change. See the canonical doc for the margin rationale. | |
| 11 | + | //! | |
| 12 | + | //! `key_cap` is an enforcement knob in `per_key` mode, **not** a billing | |
| 13 | + | //! input — SyncKit bills the developer for a pool of weight + burst, and how | |
| 14 | + | //! they allocate it across their end users is their concern. The cap exists | |
| 15 | + | //! to let developers throttle abuse / unintended growth. | |
| 19 | 16 | ||
| 20 | - | /// Egress rate in cents per GB of monthly egress quota (2× Hetzner overage). | |
| 21 | - | pub const EGRESS_RATE_CENTS_PER_GB: f64 = 0.22; | |
| 17 | + | /// Storage rate in cents per GB per month. Canonical: $0.15/GB. | |
| 18 | + | pub const STORAGE_RATE_CENTS_PER_GB: f64 = 15.0; | |
| 22 | 19 | ||
| 23 | - | /// Per-key rate in cents per month (per_key enforcement mode only). | |
| 24 | - | pub const KEY_RATE_CENTS: f64 = 2.0; | |
| 20 | + | /// Burst rate in cents per multiplier unit per GB of weight. Canonical: $0.03. | |
| 21 | + | /// Egress quota = storage_gb × egress_multiple GB per month. | |
| 22 | + | pub const EGRESS_RATE_CENTS_PER_GB: f64 = 3.0; | |
| 25 | 23 | ||
| 26 | 24 | /// Warning thresholds (percent of any cap). Matches CHECK constraint on | |
| 27 | 25 | /// `sync_app_usage_current.last_warning_pct`. | |
| @@ -29,21 +27,18 @@ pub const WARNING_THRESHOLDS_PCT: &[i16] = &[75, 90, 100]; | |||
| 29 | 27 | ||
| 30 | 28 | /// Compute the monthly Stripe invoice amount in cents for a given knob set. | |
| 31 | 29 | /// | |
| 32 | - | /// `key_cap` is `Some(n)` only when the app's `enforcement_mode = 'per_key'`. | |
| 33 | - | /// The base floor is pro-rated: small accounts pay $5, larger accounts pay | |
| 34 | - | /// only their accrued usage with no separate base line. | |
| 30 | + | /// `key_cap` is accepted but ignored — billing is on weight + burst only. | |
| 31 | + | /// The parameter stays on the signature so call sites that already thread the | |
| 32 | + | /// cap through (validators, dashboards) don't need to special-case it. | |
| 35 | 33 | pub fn monthly_price_cents( | |
| 36 | 34 | storage_gb: u32, | |
| 37 | 35 | egress_multiple: f64, | |
| 38 | - | key_cap: Option<u32>, | |
| 36 | + | _key_cap: Option<u32>, | |
| 39 | 37 | ) -> i64 { | |
| 40 | 38 | let storage = f64::from(storage_gb) * STORAGE_RATE_CENTS_PER_GB; | |
| 41 | 39 | let egress_quota_gb = f64::from(storage_gb) * egress_multiple; | |
| 42 | 40 | let egress = egress_quota_gb * EGRESS_RATE_CENTS_PER_GB; | |
| 43 | - | let keys = key_cap.map_or(0.0, |k| f64::from(k) * KEY_RATE_CENTS); | |
| 44 | - | let usage = storage + egress + keys; | |
| 45 | - | let floored = usage.max(BASE_FLOOR_CENTS as f64); | |
| 46 | - | floored.ceil() as i64 | |
| 41 | + | (storage + egress).ceil() as i64 | |
| 47 | 42 | } | |
| 48 | 43 | ||
| 49 | 44 | /// Egress quota in bytes for the given knobs. | |
| @@ -66,49 +61,55 @@ mod tests { | |||
| 66 | 61 | } | |
| 67 | 62 | ||
| 68 | 63 | #[test] | |
| 69 | - | fn floor_kicks_in_for_small_accounts() { | |
| 70 | - | // 10 GB, 3× egress → usage ≈ $0.20, hits floor. | |
| 71 | - | assert_eq!(monthly_price_cents(10, 3.0, None), 500); | |
| 72 | - | // 100 GB, 10× egress → usage ≈ $3.50, still hits floor. | |
| 73 | - | assert_eq!(monthly_price_cents(100, 10.0, None), 500); | |
| 64 | + | fn small_account_pricing() { | |
| 65 | + | // 1 GB, 5× burst → 1 × 15 + 1 × 5 × 3 = 30 cents. | |
| 66 | + | assert_eq!(monthly_price_cents(1, 5.0, None), 30); | |
| 67 | + | // 10 GB, 5× burst → 10 × 15 + 10 × 5 × 3 = 300 cents. | |
| 68 | + | assert_eq!(monthly_price_cents(10, 5.0, None), 300); | |
| 74 | 69 | } | |
| 75 | 70 | ||
| 76 | 71 | #[test] | |
| 77 | - | fn floor_disappears_when_usage_exceeds() { | |
| 78 | - | // 500 GB, 5× egress → storage $6.50 + egress $5.50 = $12.00. | |
| 79 | - | assert!(approx(monthly_price_cents(500, 5.0, None), 1200)); | |
| 80 | - | // 1 TB, 10× egress → storage $13 + egress $22 = $35. | |
| 81 | - | assert!(approx(monthly_price_cents(1024, 10.0, None), 3585)); | |
| 72 | + | fn pricing_doc_examples() { | |
| 73 | + | // From synckit_pricing.md "Example configurations": | |
| 74 | + | // 10 GB / 5× = $3.00 (Simple Productivity). | |
| 75 | + | assert_eq!(monthly_price_cents(10, 5.0, None), 300); | |
| 76 | + | // 50 GB / 5× = $15.00 (Media metadata). | |
| 77 | + | assert_eq!(monthly_price_cents(50, 5.0, None), 1500); | |
| 78 | + | // 200 GB / 5× = $60.00 (Large file sync). | |
| 79 | + | assert_eq!(monthly_price_cents(200, 5.0, None), 6000); | |
| 80 | + | // 20 GB / 10× (Builder productivity) = 300 + 600 = $9.00. | |
| 81 | + | assert_eq!(monthly_price_cents(20, 10.0, None), 900); | |
| 82 | + | // 200 GB / 3× = $48.00 (Sample manager). | |
| 83 | + | assert!(approx(monthly_price_cents(200, 3.0, None), 4800)); | |
| 84 | + | // 5 GB / 15× (collab editor) = 75 + 225 = $3.00. | |
| 85 | + | assert!(approx(monthly_price_cents(5, 15.0, None), 300)); | |
| 82 | 86 | } | |
| 83 | 87 | ||
| 84 | 88 | #[test] | |
| 85 | 89 | fn heavy_workload_pricing() { | |
| 86 | - | // 10 TB, 30× egress. | |
| 87 | - | // storage = 10240 × 1.3 = 13312 ¢ | |
| 88 | - | // egress = 10240 × 30 × 0.22 = 67584 ¢ | |
| 89 | - | // total ≈ $808 | |
| 90 | + | // 10 TB / 30× burst: | |
| 91 | + | // storage = 10240 × 15 = 153600 cents | |
| 92 | + | // egress = 10240 × 30 × 3 = 921600 cents | |
| 93 | + | // total = $10,752/mo | |
| 90 | 94 | let p = monthly_price_cents(10240, 30.0, None); | |
| 91 | - | assert!(p > 80_000 && p < 82_000, "got {p}"); | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | #[test] | |
| 95 | - | fn key_cap_adds_to_usage_in_per_key_mode() { | |
| 96 | - | // 100 GB, 10× egress, 1000 keys → usage $3.50 + $20 = $23.50. | |
| 97 | - | let p = monthly_price_cents(100, 10.0, Some(1000)); | |
| 98 | - | assert!(approx(p, 2350)); | |
| 95 | + | assert!(approx(p, 1_075_200), "got {p}"); | |
| 99 | 96 | } | |
| 100 | 97 | ||
| 101 | 98 | #[test] | |
| 102 | - | fn key_cap_can_push_through_floor() { | |
| 103 | - | // 10 GB, 1× egress, 300 keys → keys alone = $6, exceeds floor. | |
| 104 | - | let p = monthly_price_cents(10, 1.0, Some(300)); | |
| 105 | - | assert!(p > 500, "got {p}"); | |
| 99 | + | fn key_cap_does_not_affect_price() { | |
| 100 | + | // Same knobs, different key_cap → same price. Per the canonical doc, | |
| 101 | + | // key cap is an enforcement knob, not a billing input. | |
| 102 | + | let p_none = monthly_price_cents(100, 5.0, None); | |
| 103 | + | let p_low = monthly_price_cents(100, 5.0, Some(10)); | |
| 104 | + | let p_high = monthly_price_cents(100, 5.0, Some(10_000)); | |
| 105 | + | assert_eq!(p_none, p_low); | |
| 106 | + | assert_eq!(p_low, p_high); | |
| 106 | 107 | } | |
| 107 | 108 | ||
| 108 | 109 | #[test] | |
| 109 | 110 | fn fractional_multiple_works() { | |
| 110 | - | // 100 GB, 2.5× egress: storage $1.30 + egress $0.55 = $1.85 → floor. | |
| 111 | - | assert_eq!(monthly_price_cents(100, 2.5, None), 500); | |
| 111 | + | // 100 GB, 2.5× burst: 100 × 15 + 100 × 2.5 × 3 = 1500 + 750 = 2250. | |
| 112 | + | assert_eq!(monthly_price_cents(100, 2.5, None), 2250); | |
| 112 | 113 | } | |
| 113 | 114 | ||
| 114 | 115 | #[test] |
| @@ -19,16 +19,15 @@ | |||
| 19 | 19 | return '$' + dollars + '.' + (rem < 10 ? '0' : '') + rem; | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | - | function priceCents(panel, storageGb, egressMult, keyCap) { | |
| 22 | + | function priceCents(panel, storageGb, egressMult, _keyCap) { | |
| 23 | + | // Mirrors src/synckit_billing.rs::monthly_price_cents. key_cap is an | |
| 24 | + | // enforcement knob, not a billing input — accepted for signature | |
| 25 | + | // parity with the readKnobs caller, ignored here. | |
| 23 | 26 | var sRate = parseFloat(panel.dataset.storageRate); | |
| 24 | 27 | var eRate = parseFloat(panel.dataset.egressRate); | |
| 25 | - | var kRate = parseFloat(panel.dataset.keyRate); | |
| 26 | - | var floor = parseInt(panel.dataset.baseFloorCents, 10); | |
| 27 | 28 | var storage = storageGb * sRate; | |
| 28 | 29 | var egress = storageGb * egressMult * eRate; | |
| 29 | - | var keys = keyCap != null ? keyCap * kRate : 0; | |
| 30 | - | var usage = storage + egress + keys; | |
| 31 | - | return Math.ceil(Math.max(usage, floor)); | |
| 30 | + | return Math.ceil(storage + egress); | |
| 32 | 31 | } | |
| 33 | 32 | ||
| 34 | 33 | function readKnobs(panel) { |
| @@ -16,10 +16,8 @@ | |||
| 16 | 16 | data-storage-gb="{{ b.default_storage_gb }}" | |
| 17 | 17 | data-egress-multiple="{{ b.default_egress_multiple }}" | |
| 18 | 18 | data-key-cap="{% if let Some(c) = b.key_cap %}{{ c }}{% else %}100{% endif %}" | |
| 19 | - | data-storage-rate="1.3" | |
| 20 | - | data-egress-rate="0.22" | |
| 21 | - | data-key-rate="2.0" | |
| 22 | - | data-base-floor-cents="500"> | |
| 19 | + | data-storage-rate="15" | |
| 20 | + | data-egress-rate="3"> | |
| 23 | 21 | <summary class="synckit-billing-summary"> | |
| 24 | 22 | <span class="synckit-billing-status synckit-billing-status--{{ b.status }}"> | |
| 25 | 23 | {% if b.is_internal %}Internal{% else if b.status == "draft" %}Draft{% else if b.status == "active" %}Active{% else if b.status == "suspended_unpaid" %}Past due{% else if b.status == "canceled" %}Canceled{% else %}{{ b.status }}{% endif %} | |
| @@ -49,23 +47,23 @@ | |||
| 49 | 47 | {% if b.has_customer && b.status != "canceled" %} | |
| 50 | 48 | <form class="synckit-billing-form synckit-billing-section"> | |
| 51 | 49 | <div class="synckit-knob-row"> | |
| 52 | - | <label for="synckit-storage-{{ app.id }}">Storage cap</label> | |
| 50 | + | <label for="synckit-storage-{{ app.id }}">Weight</label> | |
| 53 | 51 | <input type="range" id="synckit-storage-{{ app.id }}" | |
| 54 | 52 | class="synckit-storage-slider" min="1" max="10240" step="1" | |
| 55 | 53 | value="{{ b.default_storage_gb }}"> | |
| 56 | 54 | <input type="number" class="synckit-storage-input input--sm w-100" | |
| 57 | 55 | min="1" step="1" value="{{ b.default_storage_gb }}"> | |
| 58 | - | <span class="form-hint">GB</span> | |
| 56 | + | <span class="form-hint">GB stored</span> | |
| 59 | 57 | </div> | |
| 60 | 58 | ||
| 61 | 59 | <div class="synckit-knob-row"> | |
| 62 | - | <label for="synckit-egress-{{ app.id }}">Egress quota</label> | |
| 60 | + | <label for="synckit-egress-{{ app.id }}">Burst</label> | |
| 63 | 61 | <input type="range" id="synckit-egress-{{ app.id }}" | |
| 64 | 62 | class="synckit-egress-slider" min="0.5" max="30" step="0.5" | |
| 65 | 63 | value="{{ b.default_egress_multiple }}"> | |
| 66 | 64 | <input type="number" class="synckit-egress-input input--sm w-100" | |
| 67 | 65 | min="0.1" step="0.1" value="{{ b.default_egress_multiple }}"> | |
| 68 | - | <span class="form-hint">× storage / month</span> | |
| 66 | + | <span class="form-hint">× weight / month transfer</span> | |
| 69 | 67 | </div> | |
| 70 | 68 | ||
| 71 | 69 | <div class="synckit-knob-row"> | |
| @@ -92,8 +90,8 @@ | |||
| 92 | 90 | </div> | |
| 93 | 91 | ||
| 94 | 92 | <div class="synckit-billing-summary-line"> | |
| 95 | - | <span class="synckit-price-preview">$5.00</span> | |
| 96 | - | <span class="form-hint">/ month — pro-rated $5 floor, then usage</span> | |
| 93 | + | <span class="synckit-price-preview">$0.00</span> | |
| 94 | + | <span class="form-hint">/ month — weight × $0.15 + burst × weight × $0.03</span> | |
| 97 | 95 | </div> | |
| 98 | 96 | ||
| 99 | 97 | <div class="synckit-billing-actions"> | |
| @@ -125,7 +123,7 @@ | |||
| 125 | 123 | ||
| 126 | 124 | <div class="synckit-gauge"> | |
| 127 | 125 | <div class="synckit-gauge-label"> | |
| 128 | - | <span>Egress</span> | |
| 126 | + | <span>Burst</span> | |
| 129 | 127 | <span class="synckit-gauge-value">{{ b.egress_display }}</span> | |
| 130 | 128 | </div> | |
| 131 | 129 | <div class="progress-bar-container progress-bar-container--slim progress-bar-container--rounded"> |
| @@ -103,12 +103,13 @@ async fn activate_then_get_reports_active_status_and_price() { | |||
| 103 | 103 | ).await; | |
| 104 | 104 | assert_eq!(resp.status, 200); | |
| 105 | 105 | ||
| 106 | - | // activate at small-account scale → hits $5 floor | |
| 106 | + | // Activate at the canonical "Productivity (Simple)" config: 10 GB / 5×. | |
| 107 | + | // Per synckit_pricing.md: 10 × $0.15 + 10 × 5 × $0.03 = $3.00. | |
| 107 | 108 | let resp = h.client.post_json( | |
| 108 | 109 | &format!("/api/sync/apps/{}/billing/activate", app_id), | |
| 109 | 110 | &json!({ | |
| 110 | 111 | "storage_gb_cap": 10, | |
| 111 | - | "egress_multiple": 3.0, | |
| 112 | + | "egress_multiple": 5.0, | |
| 112 | 113 | "enforcement_mode": "app_wide" | |
| 113 | 114 | }).to_string(), | |
| 114 | 115 | ).await; | |
| @@ -116,7 +117,7 @@ async fn activate_then_get_reports_active_status_and_price() { | |||
| 116 | 117 | ||
| 117 | 118 | let body: BillingUpdatedResp = resp.json(); | |
| 118 | 119 | assert_eq!(body.billing_status, "active"); | |
| 119 | - | assert_eq!(body.monthly_price_cents, 500, "10 GB / 3× should hit the $5 floor"); | |
| 120 | + | assert_eq!(body.monthly_price_cents, 300, "10 GB / 5× should be $3.00"); | |
| 120 | 121 | assert!(body.stripe_subscription_id.is_some()); | |
| 121 | 122 | ||
| 122 | 123 | // GET should agree | |
| @@ -126,10 +127,10 @@ async fn activate_then_get_reports_active_status_and_price() { | |||
| 126 | 127 | assert_eq!(status.billing_status, "active"); | |
| 127 | 128 | assert!(!status.is_internal); | |
| 128 | 129 | assert_eq!(status.storage_gb_cap, Some(10)); | |
| 129 | - | assert_eq!(status.egress_multiple, Some(3.0)); | |
| 130 | + | assert_eq!(status.egress_multiple, Some(5.0)); | |
| 130 | 131 | assert_eq!(status.enforcement_mode, "app_wide"); | |
| 131 | 132 | assert_eq!(status.key_cap, None); | |
| 132 | - | assert_eq!(status.monthly_price_cents, Some(500)); | |
| 133 | + | assert_eq!(status.monthly_price_cents, Some(300)); | |
| 133 | 134 | } | |
| 134 | 135 | ||
| 135 | 136 | #[tokio::test] | |
| @@ -144,16 +145,16 @@ async fn patch_reprices_subscription() { | |||
| 144 | 145 | &json!({ "storage_gb_cap": 10, "egress_multiple": 3.0, "enforcement_mode": "app_wide" }).to_string(), | |
| 145 | 146 | ).await; | |
| 146 | 147 | ||
| 147 | - | // PATCH up to a workload that exceeds the floor. | |
| 148 | + | // PATCH up to a larger workload and verify reprice. | |
| 149 | + | // 500 × 15 + 500 × 5 × 3 = 7500 + 7500 = 15000 cents = $150. | |
| 148 | 150 | let resp = h.client.patch_json( | |
| 149 | 151 | &format!("/api/sync/apps/{}/billing", app_id), | |
| 150 | 152 | &json!({ "storage_gb_cap": 500, "egress_multiple": 5.0, "enforcement_mode": "app_wide" }).to_string(), | |
| 151 | 153 | ).await; | |
| 152 | 154 | assert_eq!(resp.status, 200, "patch failed: {}", resp.text); | |
| 153 | 155 | let body: BillingUpdatedResp = resp.json(); | |
| 154 | - | // 500 × 1.3 + 500 × 5 × 0.22 = 650 + 550 = 1200 cents. | |
| 155 | - | assert!(body.monthly_price_cents >= 1199 && body.monthly_price_cents <= 1201, | |
| 156 | - | "expected ~$12, got {}", body.monthly_price_cents); | |
| 156 | + | assert!(body.monthly_price_cents >= 14999 && body.monthly_price_cents <= 15001, | |
| 157 | + | "expected ~$150, got {}", body.monthly_price_cents); | |
| 157 | 158 | } | |
| 158 | 159 | ||
| 159 | 160 | #[tokio::test] |