max / makenotwork
6 files changed,
+82 insertions,
-81 deletions
| @@ -3551,7 +3551,7 @@ dependencies = [ | |||
| 3551 | 3551 | ||
| 3552 | 3552 | [[package]] | |
| 3553 | 3553 | name = "makenotwork" | |
| 3554 | - | version = "0.7.1" | |
| 3554 | + | version = "0.7.0" | |
| 3555 | 3555 | dependencies = [ | |
| 3556 | 3556 | "anyhow", | |
| 3557 | 3557 | "argon2", |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.7.1" | |
| 3 | + | version = "0.7.0" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "LICENSE" | |
| 6 | 6 |
| @@ -1,25 +1,27 @@ | |||
| 1 | 1 | //! SyncKit developer billing: pricing formula and constants. | |
| 2 | 2 | //! | |
| 3 | - | //! Canonical reference: `_private/docs/mnw/server-internal/business/synckit_pricing.md`. | |
| 3 | + | //! See migration 117 for the full pricing model. Summary: | |
| 4 | 4 | //! | |
| 5 | - | //! monthly_price = storage_gb × $0.15 | |
| 6 | - | //! + storage_gb × egress_multiple × $0.03 | |
| 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)) | |
| 7 | 8 | //! | |
| 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. | |
| 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; | |
| 16 | 19 | ||
| 17 | - | /// Storage rate in cents per GB per month. Canonical: $0.15/GB. | |
| 18 | - | pub const STORAGE_RATE_CENTS_PER_GB: f64 = 15.0; | |
| 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; | |
| 19 | 22 | ||
| 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; | |
| 23 | + | /// Per-key rate in cents per month (per_key enforcement mode only). | |
| 24 | + | pub const KEY_RATE_CENTS: f64 = 2.0; | |
| 23 | 25 | ||
| 24 | 26 | /// Warning thresholds (percent of any cap). Matches CHECK constraint on | |
| 25 | 27 | /// `sync_app_usage_current.last_warning_pct`. | |
| @@ -27,18 +29,21 @@ pub const WARNING_THRESHOLDS_PCT: &[i16] = &[75, 90, 100]; | |||
| 27 | 29 | ||
| 28 | 30 | /// Compute the monthly Stripe invoice amount in cents for a given knob set. | |
| 29 | 31 | /// | |
| 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. | |
| 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. | |
| 33 | 35 | pub fn monthly_price_cents( | |
| 34 | 36 | storage_gb: u32, | |
| 35 | 37 | egress_multiple: f64, | |
| 36 | - | _key_cap: Option<u32>, | |
| 38 | + | key_cap: Option<u32>, | |
| 37 | 39 | ) -> i64 { | |
| 38 | 40 | let storage = f64::from(storage_gb) * STORAGE_RATE_CENTS_PER_GB; | |
| 39 | 41 | let egress_quota_gb = f64::from(storage_gb) * egress_multiple; | |
| 40 | 42 | let egress = egress_quota_gb * EGRESS_RATE_CENTS_PER_GB; | |
| 41 | - | (storage + egress).ceil() as i64 | |
| 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 | |
| 42 | 47 | } | |
| 43 | 48 | ||
| 44 | 49 | /// Egress quota in bytes for the given knobs. | |
| @@ -61,55 +66,49 @@ mod tests { | |||
| 61 | 66 | } | |
| 62 | 67 | ||
| 63 | 68 | #[test] | |
| 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); | |
| 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); | |
| 69 | 74 | } | |
| 70 | 75 | ||
| 71 | 76 | #[test] | |
| 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)); | |
| 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)); | |
| 86 | 82 | } | |
| 87 | 83 | ||
| 88 | 84 | #[test] | |
| 89 | 85 | fn heavy_workload_pricing() { | |
| 90 | - | // 10 TB / 30× burst: | |
| 91 | - | // storage = 10240 × 15 = 153600 cents | |
| 92 | - | // egress = 10240 × 30 × 3 = 921600 cents | |
| 93 | - | // total = $10,752/mo | |
| 86 | + | // 10 TB, 30× egress. | |
| 87 | + | // storage = 10240 × 1.3 = 13312 ¢ | |
| 88 | + | // egress = 10240 × 30 × 0.22 = 67584 ¢ | |
| 89 | + | // total ≈ $808 | |
| 94 | 90 | let p = monthly_price_cents(10240, 30.0, None); | |
| 95 | - | assert!(approx(p, 1_075_200), "got {p}"); | |
| 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)); | |
| 96 | 99 | } | |
| 97 | 100 | ||
| 98 | 101 | #[test] | |
| 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); | |
| 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}"); | |
| 107 | 106 | } | |
| 108 | 107 | ||
| 109 | 108 | #[test] | |
| 110 | 109 | fn fractional_multiple_works() { | |
| 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); | |
| 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); | |
| 113 | 112 | } | |
| 114 | 113 | ||
| 115 | 114 | #[test] |
| @@ -19,15 +19,16 @@ | |||
| 19 | 19 | return '$' + dollars + '.' + (rem < 10 ? '0' : '') + rem; | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 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. | |
| 22 | + | function priceCents(panel, storageGb, egressMult, keyCap) { | |
| 26 | 23 | var sRate = parseFloat(panel.dataset.storageRate); | |
| 27 | 24 | var eRate = parseFloat(panel.dataset.egressRate); | |
| 25 | + | var kRate = parseFloat(panel.dataset.keyRate); | |
| 26 | + | var floor = parseInt(panel.dataset.baseFloorCents, 10); | |
| 28 | 27 | var storage = storageGb * sRate; | |
| 29 | 28 | var egress = storageGb * egressMult * eRate; | |
| 30 | - | return Math.ceil(storage + egress); | |
| 29 | + | var keys = keyCap != null ? keyCap * kRate : 0; | |
| 30 | + | var usage = storage + egress + keys; | |
| 31 | + | return Math.ceil(Math.max(usage, floor)); | |
| 31 | 32 | } | |
| 32 | 33 | ||
| 33 | 34 | function readKnobs(panel) { |
| @@ -16,8 +16,10 @@ | |||
| 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="15" | |
| 20 | - | data-egress-rate="3"> | |
| 19 | + | data-storage-rate="1.3" | |
| 20 | + | data-egress-rate="0.22" | |
| 21 | + | data-key-rate="2.0" | |
| 22 | + | data-base-floor-cents="500"> | |
| 21 | 23 | <summary class="synckit-billing-summary"> | |
| 22 | 24 | <span class="synckit-billing-status synckit-billing-status--{{ b.status }}"> | |
| 23 | 25 | {% 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 %} | |
| @@ -47,23 +49,23 @@ | |||
| 47 | 49 | {% if b.has_customer && b.status != "canceled" %} | |
| 48 | 50 | <form class="synckit-billing-form synckit-billing-section"> | |
| 49 | 51 | <div class="synckit-knob-row"> | |
| 50 | - | <label for="synckit-storage-{{ app.id }}">Weight</label> | |
| 52 | + | <label for="synckit-storage-{{ app.id }}">Storage cap</label> | |
| 51 | 53 | <input type="range" id="synckit-storage-{{ app.id }}" | |
| 52 | 54 | class="synckit-storage-slider" min="1" max="10240" step="1" | |
| 53 | 55 | value="{{ b.default_storage_gb }}"> | |
| 54 | 56 | <input type="number" class="synckit-storage-input input--sm w-100" | |
| 55 | 57 | min="1" step="1" value="{{ b.default_storage_gb }}"> | |
| 56 | - | <span class="form-hint">GB stored</span> | |
| 58 | + | <span class="form-hint">GB</span> | |
| 57 | 59 | </div> | |
| 58 | 60 | ||
| 59 | 61 | <div class="synckit-knob-row"> | |
| 60 | - | <label for="synckit-egress-{{ app.id }}">Burst</label> | |
| 62 | + | <label for="synckit-egress-{{ app.id }}">Egress quota</label> | |
| 61 | 63 | <input type="range" id="synckit-egress-{{ app.id }}" | |
| 62 | 64 | class="synckit-egress-slider" min="0.5" max="30" step="0.5" | |
| 63 | 65 | value="{{ b.default_egress_multiple }}"> | |
| 64 | 66 | <input type="number" class="synckit-egress-input input--sm w-100" | |
| 65 | 67 | min="0.1" step="0.1" value="{{ b.default_egress_multiple }}"> | |
| 66 | - | <span class="form-hint">× weight / month transfer</span> | |
| 68 | + | <span class="form-hint">× storage / month</span> | |
| 67 | 69 | </div> | |
| 68 | 70 | ||
| 69 | 71 | <div class="synckit-knob-row"> | |
| @@ -90,8 +92,8 @@ | |||
| 90 | 92 | </div> | |
| 91 | 93 | ||
| 92 | 94 | <div class="synckit-billing-summary-line"> | |
| 93 | - | <span class="synckit-price-preview">$0.00</span> | |
| 94 | - | <span class="form-hint">/ month — weight × $0.15 + burst × weight × $0.03</span> | |
| 95 | + | <span class="synckit-price-preview">$5.00</span> | |
| 96 | + | <span class="form-hint">/ month — pro-rated $5 floor, then usage</span> | |
| 95 | 97 | </div> | |
| 96 | 98 | ||
| 97 | 99 | <div class="synckit-billing-actions"> | |
| @@ -123,7 +125,7 @@ | |||
| 123 | 125 | ||
| 124 | 126 | <div class="synckit-gauge"> | |
| 125 | 127 | <div class="synckit-gauge-label"> | |
| 126 | - | <span>Burst</span> | |
| 128 | + | <span>Egress</span> | |
| 127 | 129 | <span class="synckit-gauge-value">{{ b.egress_display }}</span> | |
| 128 | 130 | </div> | |
| 129 | 131 | <div class="progress-bar-container progress-bar-container--slim progress-bar-container--rounded"> |
| @@ -103,13 +103,12 @@ 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 the canonical "Productivity (Simple)" config: 10 GB / 5×. | |
| 107 | - | // Per synckit_pricing.md: 10 × $0.15 + 10 × 5 × $0.03 = $3.00. | |
| 106 | + | // activate at small-account scale → hits $5 floor | |
| 108 | 107 | let resp = h.client.post_json( | |
| 109 | 108 | &format!("/api/sync/apps/{}/billing/activate", app_id), | |
| 110 | 109 | &json!({ | |
| 111 | 110 | "storage_gb_cap": 10, | |
| 112 | - | "egress_multiple": 5.0, | |
| 111 | + | "egress_multiple": 3.0, | |
| 113 | 112 | "enforcement_mode": "app_wide" | |
| 114 | 113 | }).to_string(), | |
| 115 | 114 | ).await; | |
| @@ -117,7 +116,7 @@ async fn activate_then_get_reports_active_status_and_price() { | |||
| 117 | 116 | ||
| 118 | 117 | let body: BillingUpdatedResp = resp.json(); | |
| 119 | 118 | assert_eq!(body.billing_status, "active"); | |
| 120 | - | assert_eq!(body.monthly_price_cents, 300, "10 GB / 5× should be $3.00"); | |
| 119 | + | assert_eq!(body.monthly_price_cents, 500, "10 GB / 3× should hit the $5 floor"); | |
| 121 | 120 | assert!(body.stripe_subscription_id.is_some()); | |
| 122 | 121 | ||
| 123 | 122 | // GET should agree | |
| @@ -127,10 +126,10 @@ async fn activate_then_get_reports_active_status_and_price() { | |||
| 127 | 126 | assert_eq!(status.billing_status, "active"); | |
| 128 | 127 | assert!(!status.is_internal); | |
| 129 | 128 | assert_eq!(status.storage_gb_cap, Some(10)); | |
| 130 | - | assert_eq!(status.egress_multiple, Some(5.0)); | |
| 129 | + | assert_eq!(status.egress_multiple, Some(3.0)); | |
| 131 | 130 | assert_eq!(status.enforcement_mode, "app_wide"); | |
| 132 | 131 | assert_eq!(status.key_cap, None); | |
| 133 | - | assert_eq!(status.monthly_price_cents, Some(300)); | |
| 132 | + | assert_eq!(status.monthly_price_cents, Some(500)); | |
| 134 | 133 | } | |
| 135 | 134 | ||
| 136 | 135 | #[tokio::test] | |
| @@ -145,16 +144,16 @@ async fn patch_reprices_subscription() { | |||
| 145 | 144 | &json!({ "storage_gb_cap": 10, "egress_multiple": 3.0, "enforcement_mode": "app_wide" }).to_string(), | |
| 146 | 145 | ).await; | |
| 147 | 146 | ||
| 148 | - | // PATCH up to a larger workload and verify reprice. | |
| 149 | - | // 500 × 15 + 500 × 5 × 3 = 7500 + 7500 = 15000 cents = $150. | |
| 147 | + | // PATCH up to a workload that exceeds the floor. | |
| 150 | 148 | let resp = h.client.patch_json( | |
| 151 | 149 | &format!("/api/sync/apps/{}/billing", app_id), | |
| 152 | 150 | &json!({ "storage_gb_cap": 500, "egress_multiple": 5.0, "enforcement_mode": "app_wide" }).to_string(), | |
| 153 | 151 | ).await; | |
| 154 | 152 | assert_eq!(resp.status, 200, "patch failed: {}", resp.text); | |
| 155 | 153 | let body: BillingUpdatedResp = resp.json(); | |
| 156 | - | assert!(body.monthly_price_cents >= 14999 && body.monthly_price_cents <= 15001, | |
| 157 | - | "expected ~$150, got {}", body.monthly_price_cents); | |
| 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); | |
| 158 | 157 | } | |
| 159 | 158 | ||
| 160 | 159 | #[tokio::test] |