Skip to main content

max / makenotwork

v0.7.1: reconcile SyncKit prices to canonical doc Brings src/synckit_billing.rs into line with _private/docs/mnw/server-internal/business/synckit_pricing.md. The prior Phase 1 implementation was calibrated to 2x Hetzner Object Storage cost (STORAGE_RATE_CENTS_PER_GB = 1.3, EGRESS_RATE_CENTS_PER_GB = 0.22) — the canonical doc sits at ~95% margin (STORAGE = 15, EGRESS = 3) to survive a move to AWS S3 / R2 without customer-facing price changes. Also drops two implementation details the doc doesn't include: - $5 pro-rated floor (was a Stripe-fee guard; doc accepts small-account margin loss to keep "no overages, predictable bill" promise clean). - $0.02/key billing rate in per_key mode. The canonical model bills per developer pool of weight + burst; how the developer allocates that across end users is their concern. key_cap remains as an enforcement knob (refuses new claims past the cap) but contributes nothing to the price. UI labels in the dashboard panel: "Storage cap" → "Weight", "Egress quota" → "Burst", to match the doc's terminology. Internal field name egress_multiple stays for now — full rename tracked in todo.md § 4f. Integration test expectations updated; matches the canonical doc's Example Configurations table. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-21 22:23 UTC
Commit: df0f97a98e2f2283c85f1255c8d24a8e85e05a8b
Parent: feb21d9
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]