Skip to main content

max / makenotwork

Revert "v0.7.1: reconcile SyncKit prices to canonical doc" This reverts commit df0f97a98e2f2283c85f1255c8d24a8e85e05a8b.
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-21 22:29 UTC
Commit: 456479e55e857206b9e64efb911e18741f4a268c
Parent: df0f97a
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]