Skip to main content

max / makenotwork

v0.7.3: SyncKit pricing v2.1 — two modes, GB-only pricing, R2-calibrated Reworks the SyncKit billing model based on the morning's discussion. The prior implementation priced storage + an egress_multiple burst knob; the new model is pure GB pricing with two mode selectors. Bulk mode (default): monthly_cost = storage_gb_cap × $0.03 (floored at $0.31) Storage fills → all uploads refused. Existing data still reads. Per-key mode: monthly_cost = key_cap × gb_per_key × $0.03 (floored at $0.31) Key cap reached → new claim_key returns 402 key_limit_reached. Effective storage cap = key_cap × gb_per_key (enforced app-wide). Rate is calibrated to 2× Cloudflare R2 ($0.015/GB storage, $0 egress) since SyncKit blobs will land on a dedicated R2 bucket — see todo.md § 4e and human_todo.md for the R2 bucket setup task. The 2× margin absorbs ingress/egress entirely so the developer only pays for GB. Floor $0.31/month: smallest invoice that nets ≥ $0 after Stripe's 2.9% + $0.30 per-charge fee. Below that we'd lose money on the txn. Egress is no longer enforced as a cap — kept as a free dashboard metric (bytes_egress_period continues to track) so devs can see how their app behaves without it driving a bill. Mode rename: 'app_wide' → 'bulk'. Schema rework via migration 118. Canonical doc rewritten: synckit_pricing.md now matches the implementation 1:1. Integration tests cover both modes. 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:56 UTC
Commit: fbff1a448f80903c8b4b0821f17941ec426e57e8
Parent: 2bc0811
14 files changed, +513 insertions, -411 deletions
@@ -3551,7 +3551,7 @@ dependencies = [
3551 3551
3552 3552 [[package]]
3553 3553 name = "makenotwork"
3554 - version = "0.7.2"
3554 + version = "0.7.3"
3555 3555 dependencies = [
3556 3556 "anyhow",
3557 3557 "argon2",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.7.2"
3 + version = "0.7.3"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -0,0 +1,60 @@
1 + -- SyncKit billing v2.1: GB-only pricing, two modes.
2 + --
3 + -- Replaces the storage + egress_multiple formula with pure GB pricing.
4 + -- Egress and ingress are absorbed in the storage rate's 2x margin. We still
5 + -- TRACK egress in sync_app_usage_current for developer-facing stats, but no
6 + -- longer enforce a cap on it.
7 + --
8 + -- Two modes (the `enforcement_mode` column drives both pricing and limits):
9 + --
10 + -- bulk — developer sets `storage_gb_cap`. Price = storage_gb_cap × rate.
11 + -- When storage fills, the whole app's uploads degrade.
12 + --
13 + -- per_key — developer sets `key_cap` AND `gb_per_key`.
14 + -- Price = key_cap × gb_per_key × rate.
15 + -- Each key gets its own gb_per_key allotment. A full key
16 + -- degrades only that key; other keys keep working.
17 + --
18 + -- Mode rename: 'app_wide' → 'bulk' (terminology change for clarity).
19 +
20 + -- Drop the existing shape constraints so we can rework them.
21 + ALTER TABLE sync_apps DROP CONSTRAINT sync_apps_billing_shape;
22 + ALTER TABLE sync_apps DROP CONSTRAINT sync_apps_key_cap_shape;
23 +
24 + -- Egress is no longer a price input. We still track usage in
25 + -- sync_app_usage_current.bytes_egress_period for stats.
26 + ALTER TABLE sync_apps DROP COLUMN egress_multiple;
27 +
28 + -- New per-key allotment knob. Required in per_key mode, NULL in bulk mode.
29 + ALTER TABLE sync_apps ADD COLUMN gb_per_key INT
30 + CHECK (gb_per_key IS NULL OR gb_per_key > 0);
31 +
32 + -- Rename enforcement_mode value 'app_wide' → 'bulk'. Drop the old CHECK first
33 + -- so we can rewrite values, then add the new CHECK.
34 + ALTER TABLE sync_apps DROP CONSTRAINT sync_apps_enforcement_mode_check;
35 + UPDATE sync_apps SET enforcement_mode = 'bulk' WHERE enforcement_mode = 'app_wide';
36 + ALTER TABLE sync_apps ALTER COLUMN enforcement_mode SET DEFAULT 'bulk';
37 + ALTER TABLE sync_apps ADD CONSTRAINT sync_apps_enforcement_mode_check
38 + CHECK (enforcement_mode IN ('per_key', 'bulk'));
39 +
40 + -- New shape constraint: in bulk mode, storage_gb_cap is required and gb_per_key
41 + -- + key_cap are forbidden; in per_key mode, key_cap + gb_per_key are required
42 + -- and storage_gb_cap is forbidden. Drafts and canceled apps are exempt
43 + -- (no knobs set yet), as are internal apps (no billing).
44 + ALTER TABLE sync_apps ADD CONSTRAINT sync_apps_billing_shape CHECK (
45 + is_internal
46 + OR billing_status = 'draft'
47 + OR billing_status = 'canceled'
48 + OR (
49 + enforcement_mode = 'bulk'
50 + AND storage_gb_cap IS NOT NULL
51 + AND key_cap IS NULL
52 + AND gb_per_key IS NULL
53 + )
54 + OR (
55 + enforcement_mode = 'per_key'
56 + AND key_cap IS NOT NULL
57 + AND gb_per_key IS NOT NULL
58 + AND storage_gb_cap IS NULL
59 + )
60 + );
@@ -131,17 +131,16 @@ pub struct DbSyncAppBilling {
131 131 pub stripe_subscription_id: Option<String>,
132 132 /// 'draft' | 'active' | 'suspended_unpaid' | 'canceled'
133 133 pub billing_status: String,
134 - /// Storage cap in GB (NULL while in draft).
134 + /// Storage cap in GB. Set in bulk mode; NULL in per_key mode (capacity is
135 + /// derived from `key_cap × gb_per_key`) and in draft.
135 136 pub storage_gb_cap: Option<i32>,
136 - /// Egress quota = storage_gb_cap × egress_multiple (per month).
137 - /// Decoded as `f64` via explicit `CAST(... AS DOUBLE PRECISION)` in queries
138 - /// (sqlx doesn't support PostgreSQL NUMERIC → f64 directly without the
139 - /// `bigdecimal` or `rust_decimal` feature, neither of which is enabled).
140 - pub egress_multiple: Option<f64>,
141 - /// 'per_key' | 'app_wide'
137 + /// `'per_key'` | `'bulk'`. Drives both pricing and degradation behavior.
142 138 pub enforcement_mode: String,
143 - /// Required iff enforcement_mode = 'per_key'.
139 + /// Max active keys. Set in per_key mode; NULL in bulk mode.
144 140 pub key_cap: Option<i32>,
141 + /// GB allotment per active key (per_key mode only). Total storage
142 + /// capacity = `key_cap × gb_per_key`.
143 + pub gb_per_key: Option<i32>,
145 144 pub current_period_start: Option<DateTime<Utc>>,
146 145 pub current_period_end: Option<DateTime<Utc>>,
147 146 // sync_app_usage_current (LEFT-joined, may be missing if row absent)
@@ -77,10 +77,10 @@ pub async fn set_stripe_customer(
77 77 pub async fn activate_billing(
78 78 pool: &PgPool,
79 79 app_id: SyncAppId,
80 - storage_gb_cap: i32,
81 - egress_multiple: f64,
82 80 enforcement_mode: &str,
81 + storage_gb_cap: Option<i32>,
83 82 key_cap: Option<i32>,
83 + gb_per_key: Option<i32>,
84 84 stripe_sub_id: &str,
85 85 period_start: DateTime<Utc>,
86 86 period_end: DateTime<Utc>,
@@ -90,10 +90,10 @@ pub async fn activate_billing(
90 90 UPDATE sync_apps SET
91 91 billing_status = 'active',
92 92 stripe_subscription_id = $2,
93 - storage_gb_cap = $3,
94 - egress_multiple = CAST($4 AS NUMERIC(6, 2)),
95 - enforcement_mode = $5,
96 - key_cap = $6,
93 + enforcement_mode = $3,
94 + storage_gb_cap = $4,
95 + key_cap = $5,
96 + gb_per_key = $6,
97 97 current_period_start = $7,
98 98 current_period_end = $8
99 99 WHERE id = $1
@@ -101,10 +101,10 @@ pub async fn activate_billing(
101 101 )
102 102 .bind(app_id)
103 103 .bind(stripe_sub_id)
104 - .bind(storage_gb_cap)
105 - .bind(egress_multiple)
106 104 .bind(enforcement_mode)
105 + .bind(storage_gb_cap)
107 106 .bind(key_cap)
107 + .bind(gb_per_key)
108 108 .bind(period_start)
109 109 .bind(period_end)
110 110 .execute(pool)
@@ -117,26 +117,26 @@ pub async fn activate_billing(
117 117 pub async fn update_knobs(
118 118 pool: &PgPool,
119 119 app_id: SyncAppId,
120 - storage_gb_cap: i32,
121 - egress_multiple: f64,
122 120 enforcement_mode: &str,
121 + storage_gb_cap: Option<i32>,
123 122 key_cap: Option<i32>,
123 + gb_per_key: Option<i32>,
124 124 ) -> Result<()> {
125 125 sqlx::query(
126 126 r#"
127 127 UPDATE sync_apps SET
128 - storage_gb_cap = $2,
129 - egress_multiple = CAST($3 AS NUMERIC(6, 2)),
130 - enforcement_mode = $4,
131 - key_cap = $5
128 + enforcement_mode = $2,
129 + storage_gb_cap = $3,
130 + key_cap = $4,
131 + gb_per_key = $5
132 132 WHERE id = $1
133 133 "#,
134 134 )
135 135 .bind(app_id)
136 - .bind(storage_gb_cap)
137 - .bind(egress_multiple)
138 136 .bind(enforcement_mode)
137 + .bind(storage_gb_cap)
139 138 .bind(key_cap)
139 + .bind(gb_per_key)
140 140 .execute(pool)
141 141 .await?;
142 142 Ok(())
@@ -242,9 +242,9 @@ pub async fn get_app_with_billing(
242 242 sa.stripe_subscription_id,
243 243 sa.billing_status,
244 244 sa.storage_gb_cap,
245 - CAST(sa.egress_multiple AS DOUBLE PRECISION) AS egress_multiple,
246 245 sa.enforcement_mode,
247 246 sa.key_cap,
247 + sa.gb_per_key,
248 248 sa.current_period_start,
249 249 sa.current_period_end,
250 250 u.bytes_stored,
@@ -284,9 +284,9 @@ pub async fn get_apps_with_billing_by_creator(
284 284 sa.stripe_subscription_id,
285 285 sa.billing_status,
286 286 sa.storage_gb_cap,
287 - CAST(sa.egress_multiple AS DOUBLE PRECISION) AS egress_multiple,
288 287 sa.enforcement_mode,
289 288 sa.key_cap,
289 + sa.gb_per_key,
290 290 sa.current_period_start,
291 291 sa.current_period_end,
292 292 u.bytes_stored,
@@ -325,9 +325,9 @@ pub async fn get_apps_with_billing_by_project(
325 325 sa.stripe_subscription_id,
326 326 sa.billing_status,
327 327 sa.storage_gb_cap,
328 - CAST(sa.egress_multiple AS DOUBLE PRECISION) AS egress_multiple,
329 328 sa.enforcement_mode,
330 329 sa.key_cap,
330 + sa.gb_per_key,
331 331 sa.current_period_start,
332 332 sa.current_period_end,
333 333 u.bytes_stored,
@@ -580,9 +580,11 @@ pub async fn would_exceed_storage(
580 580 app_id: SyncAppId,
581 581 additional_bytes: i64,
582 582 ) -> Result<Option<ExceededLimit>> {
583 - let row: Option<(bool, Option<i32>, Option<i64>)> = sqlx::query_as(
583 + let row: Option<(bool, String, Option<i32>, Option<i32>, Option<i32>, Option<i64>)> = sqlx::query_as(
584 584 r#"
585 - SELECT sa.is_internal, sa.storage_gb_cap, u.bytes_stored
585 + SELECT sa.is_internal, sa.enforcement_mode,
586 + sa.storage_gb_cap, sa.key_cap, sa.gb_per_key,
587 + u.bytes_stored
586 588 FROM sync_apps sa
587 589 LEFT JOIN sync_app_usage_current u ON u.app_id = sa.id
588 590 WHERE sa.id = $1
@@ -592,9 +594,21 @@ pub async fn would_exceed_storage(
592 594 .fetch_optional(pool)
593 595 .await?;
594 596
595 - let Some((is_internal, gb_cap, bytes_stored)) = row else { return Ok(None); };
597 + let Some((is_internal, mode, storage_gb, key_cap, gb_per_key, bytes_stored)) = row else { return Ok(None); };
596 598 if is_internal { return Ok(None); }
597 - let Some(gb) = gb_cap else { return Ok(None); };
599 +
600 + // Effective storage cap depends on mode. Per-key degradation is currently
601 + // app-wide at the enforcement layer (we cap on key_cap × gb_per_key); the
602 + // dev-side UX can present per-key feedback using their own claim tracking.
603 + let gb = match mode.as_str() {
604 + "bulk" => match storage_gb { Some(g) => g, None => return Ok(None) },
605 + "per_key" => match (key_cap, gb_per_key) {
606 + (Some(k), Some(g)) => k.saturating_mul(g),
607 + _ => return Ok(None),
608 + },
609 + _ => return Ok(None),
610 + };
611 +
598 612 let limit = crate::synckit_billing::storage_cap_bytes(gb as u32);
599 613 let used = bytes_stored.unwrap_or(0);
600 614 if used.saturating_add(additional_bytes) > limit {
@@ -604,41 +618,6 @@ pub async fn would_exceed_storage(
604 618 }
605 619 }
606 620
607 - /// Check whether `additional_bytes` more egress would exceed the period quota.
608 - /// Same caveats as `would_exceed_storage`.
609 - #[tracing::instrument(skip_all)]
610 - pub async fn would_exceed_egress(
611 - pool: &PgPool,
612 - app_id: SyncAppId,
613 - additional_bytes: i64,
614 - ) -> Result<Option<ExceededLimit>> {
615 - let row: Option<(bool, Option<i32>, Option<f64>, Option<i64>)> = sqlx::query_as(
616 - r#"
617 - SELECT sa.is_internal,
618 - sa.storage_gb_cap,
619 - CAST(sa.egress_multiple AS DOUBLE PRECISION),
620 - u.bytes_egress_period
621 - FROM sync_apps sa
622 - LEFT JOIN sync_app_usage_current u ON u.app_id = sa.id
623 - WHERE sa.id = $1
624 - "#,
625 - )
626 - .bind(app_id)
627 - .fetch_optional(pool)
628 - .await?;
629 -
630 - let Some((is_internal, gb_cap, egress_mult, egress_used)) = row else { return Ok(None); };
631 - if is_internal { return Ok(None); }
632 - let (Some(gb), Some(mult)) = (gb_cap, egress_mult) else { return Ok(None); };
633 - let limit = crate::synckit_billing::egress_quota_bytes(gb as u32, mult);
634 - let used = egress_used.unwrap_or(0);
635 - if used.saturating_add(additional_bytes) > limit {
636 - Ok(Some(ExceededLimit { dimension: "egress", used, limit }))
637 - } else {
638 - Ok(None)
639 - }
640 - }
641 -
642 621 /// Fetch the list of active, non-internal apps that may need a warning email,
643 622 /// along with the data needed to compute which threshold (if any) has been
644 623 /// breached since the last notice. The per-app breach computation is done in
@@ -647,18 +626,20 @@ pub async fn would_exceed_egress(
647 626 pub async fn get_apps_needing_warning(pool: &PgPool) -> Result<Vec<WarningCandidate>> {
648 627 use super::id_types::UserId;
649 628
650 - // Row shape from the DB; converted into one or two WarningCandidate
651 - // entries (storage + egress) per app by the caller.
629 + // Egress is no longer a price input or an enforced cap (see migration 118),
630 + // so the only dimension that warrants a usage warning is storage. The
631 + // effective storage cap depends on enforcement_mode.
652 632 #[derive(sqlx::FromRow)]
653 633 struct Row {
654 634 app_id: SyncAppId,
655 635 creator_id: UserId,
656 636 creator_email: String,
657 637 app_name: String,
638 + enforcement_mode: String,
658 639 storage_gb_cap: Option<i32>,
659 - egress_multiple: Option<f64>,
640 + key_cap: Option<i32>,
641 + gb_per_key: Option<i32>,
660 642 bytes_stored: i64,
661 - bytes_egress_period: i64,
662 643 last_warning_pct: i16,
663 644 }
664 645
@@ -669,10 +650,11 @@ pub async fn get_apps_needing_warning(pool: &PgPool) -> Result<Vec<WarningCandid
669 650 sa.creator_id AS creator_id,
670 651 u_user.email AS creator_email,
671 652 sa.name AS app_name,
653 + sa.enforcement_mode AS enforcement_mode,
672 654 sa.storage_gb_cap AS storage_gb_cap,
673 - CAST(sa.egress_multiple AS DOUBLE PRECISION) AS egress_multiple,
655 + sa.key_cap AS key_cap,
656 + sa.gb_per_key AS gb_per_key,
674 657 COALESCE(u.bytes_stored, 0) AS bytes_stored,
675 - COALESCE(u.bytes_egress_period, 0) AS bytes_egress_period,
676 658 COALESCE(u.last_warning_pct, 0) AS last_warning_pct
677 659 FROM sync_apps sa
678 660 JOIN users u_user ON u_user.id = sa.creator_id
@@ -686,41 +668,29 @@ pub async fn get_apps_needing_warning(pool: &PgPool) -> Result<Vec<WarningCandid
686 668
687 669 let mut out = Vec::new();
688 670 for r in rows {
689 - // Storage breach
690 - if let Some(gb) = r.storage_gb_cap {
691 - let limit = crate::synckit_billing::storage_cap_bytes(gb as u32);
692 - if let Some(pct) = highest_breached_threshold(
693 - r.bytes_stored, limit, r.last_warning_pct,
694 - ) {
695 - out.push(WarningCandidate {
696 - app_id: r.app_id,
697 - creator_id: r.creator_id,
698 - creator_email: r.creator_email.clone(),
699 - app_name: r.app_name.clone(),
700 - threshold_pct: pct,
701 - dimension: "storage",
702 - used: r.bytes_stored,
703 - limit,
704 - });
705 - }
706 - }
707 - // Egress breach
708 - if let (Some(gb), Some(mult)) = (r.storage_gb_cap, r.egress_multiple) {
709 - let limit = crate::synckit_billing::egress_quota_bytes(gb as u32, mult);
710 - if let Some(pct) = highest_breached_threshold(
711 - r.bytes_egress_period, limit, r.last_warning_pct,
712 - ) {
713 - out.push(WarningCandidate {
714 - app_id: r.app_id,
715 - creator_id: r.creator_id,
716 - creator_email: r.creator_email,
717 - app_name: r.app_name,
718 - threshold_pct: pct,
719 - dimension: "egress",
720 - used: r.bytes_egress_period,
721 - limit,
722 - });
723 - }
671 + let gb = match r.enforcement_mode.as_str() {
672 + "bulk" => r.storage_gb_cap,
673 + "per_key" => match (r.key_cap, r.gb_per_key) {
674 + (Some(k), Some(g)) => Some(k.saturating_mul(g)),
675 + _ => None,
676 + },
677 + _ => None,
678 + };
679 + let Some(gb) = gb else { continue };
680 + let limit = crate::synckit_billing::storage_cap_bytes(gb as u32);
681 + if let Some(pct) = highest_breached_threshold(
682 + r.bytes_stored, limit, r.last_warning_pct,
683 + ) {
684 + out.push(WarningCandidate {
685 + app_id: r.app_id,
686 + creator_id: r.creator_id,
687 + creator_email: r.creator_email,
688 + app_name: r.app_name,
689 + threshold_pct: pct,
690 + dimension: "storage",
691 + used: r.bytes_stored,
692 + limit,
693 + });
724 694 }
725 695 }
726 696 Ok(out)
@@ -97,7 +97,7 @@ pub(super) async fn activate(
97 97 Json(req): Json<BillingActivateRequest>,
98 98 ) -> Result<impl IntoResponse> {
99 99 user.check_not_sandbox()?;
100 - validate_knobs(&req.enforcement_mode, req.storage_gb_cap, req.egress_multiple, req.key_cap)?;
100 + validate_knobs(&req.enforcement_mode, req.storage_gb_cap, req.key_cap, req.gb_per_key)?;
101 101
102 102 let app = db::synckit_billing::get_app_with_billing(&state.db, app_id)
103 103 .await?
@@ -117,7 +117,12 @@ pub(super) async fn activate(
117 117 )
118 118 })?;
119 119
120 - let price_cents = monthly_price_cents(req.storage_gb_cap, req.egress_multiple, req.key_cap);
120 + let price_cents = monthly_price_cents(
121 + &req.enforcement_mode,
122 + req.storage_gb_cap,
123 + req.key_cap,
124 + req.gb_per_key,
125 + );
121 126
122 127 let stripe = state
123 128 .stripe
@@ -135,10 +140,10 @@ pub(super) async fn activate(
135 140 db::synckit_billing::activate_billing(
136 141 &state.db,
137 142 app_id,
138 - req.storage_gb_cap as i32,
139 - req.egress_multiple,
140 143 &req.enforcement_mode,
141 - req.key_cap.map(|c| c as i32),
144 + req.storage_gb_cap.map(|v| v as i32),
145 + req.key_cap.map(|v| v as i32),
146 + req.gb_per_key.map(|v| v as i32),
142 147 &sub.subscription_id,
143 148 period_start,
144 149 period_end,
@@ -163,7 +168,7 @@ pub(super) async fn patch(
163 168 Json(req): Json<BillingPatchRequest>,
164 169 ) -> Result<impl IntoResponse> {
165 170 user.check_not_sandbox()?;
166 - validate_knobs(&req.enforcement_mode, req.storage_gb_cap, req.egress_multiple, req.key_cap)?;
171 + validate_knobs(&req.enforcement_mode, req.storage_gb_cap, req.key_cap, req.gb_per_key)?;
167 172
168 173 let app = db::synckit_billing::get_app_with_billing(&state.db, app_id)
169 174 .await?
@@ -183,7 +188,12 @@ pub(super) async fn patch(
183 188 ))
184 189 })?;
185 190
186 - let new_price = monthly_price_cents(req.storage_gb_cap, req.egress_multiple, req.key_cap);
191 + let new_price = monthly_price_cents(
192 + &req.enforcement_mode,
193 + req.storage_gb_cap,
194 + req.key_cap,
195 + req.gb_per_key,
196 + );
187 197
188 198 let stripe = state
189 199 .stripe
@@ -196,10 +206,10 @@ pub(super) async fn patch(
196 206 db::synckit_billing::update_knobs(
197 207 &state.db,
198 208 app_id,
199 - req.storage_gb_cap as i32,
200 - req.egress_multiple,
201 209 &req.enforcement_mode,
202 - req.key_cap.map(|c| c as i32),
210 + req.storage_gb_cap.map(|v| v as i32),
211 + req.key_cap.map(|v| v as i32),
212 + req.gb_per_key.map(|v| v as i32),
203 213 )
204 214 .await?;
205 215
@@ -260,23 +270,26 @@ pub(super) async fn get(
260 270 return Err(AppError::Forbidden);
261 271 }
262 272
263 - let monthly_price_cents = match (app.storage_gb_cap, app.egress_multiple) {
264 - (Some(gb), Some(mult)) => Some(monthly_price_cents(
265 - gb as u32,
266 - mult,
267 - app.key_cap.map(|c| c as u32),
268 - )),
269 - _ => None,
273 + let knobs_set = match app.enforcement_mode.as_str() {
274 + "bulk" => app.storage_gb_cap.is_some(),
275 + "per_key" => app.key_cap.is_some() && app.gb_per_key.is_some(),
276 + _ => false,
270 277 };
278 + let monthly_price_cents = knobs_set.then(|| monthly_price_cents(
279 + &app.enforcement_mode,
280 + app.storage_gb_cap.map(|v| v as u32),
281 + app.key_cap.map(|v| v as u32),
282 + app.gb_per_key.map(|v| v as u32),
283 + ));
271 284
272 285 Ok(Json(BillingStatusResponse {
273 286 app_id,
274 287 billing_status: app.billing_status,
275 288 is_internal: app.is_internal,
276 - storage_gb_cap: app.storage_gb_cap.map(|v| v as u32),
277 - egress_multiple: app.egress_multiple,
278 289 enforcement_mode: app.enforcement_mode,
290 + storage_gb_cap: app.storage_gb_cap.map(|v| v as u32),
279 291 key_cap: app.key_cap.map(|v| v as u32),
292 + gb_per_key: app.gb_per_key.map(|v| v as u32),
280 293 bytes_stored: app.bytes_stored.unwrap_or(0),
281 294 bytes_egress_period: app.bytes_egress_period.unwrap_or(0),
282 295 keys_claimed: app.keys_claimed.unwrap_or(0) as u32,
@@ -347,38 +360,46 @@ fn synckit_return_url(
347 360
348 361 fn validate_knobs(
349 362 enforcement_mode: &str,
350 - storage_gb_cap: u32,
351 - egress_multiple: f64,
363 + storage_gb_cap: Option<u32>,
352 364 key_cap: Option<u32>,
365 + gb_per_key: Option<u32>,
353 366 ) -> Result<()> {
354 - if storage_gb_cap == 0 {
355 - return Err(AppError::BadRequest(
356 - "storage_gb_cap must be > 0".to_string(),
357 - ));
358 - }
359 - if !(egress_multiple > 0.0 && egress_multiple.is_finite()) {
360 - return Err(AppError::BadRequest(
361 - "egress_multiple must be > 0".to_string(),
362 - ));
363 - }
364 367 match enforcement_mode {
365 - "per_key" => {
366 - if key_cap.is_none_or(|c| c == 0) {
368 + "bulk" => {
369 + match storage_gb_cap {
370 + Some(v) if v > 0 => {}
371 + _ => return Err(AppError::BadRequest(
372 + "storage_gb_cap (> 0) is required when enforcement_mode = bulk".to_string(),
373 + )),
374 + }
375 + if key_cap.is_some() || gb_per_key.is_some() {
367 376 return Err(AppError::BadRequest(
368 - "key_cap (> 0) is required when enforcement_mode = per_key".to_string(),
377 + "key_cap and gb_per_key must be omitted when enforcement_mode = bulk".to_string(),
369 378 ));
370 379 }
371 380 }
372 - "app_wide" => {
373 - if key_cap.is_some() {
381 + "per_key" => {
382 + match key_cap {
383 + Some(v) if v > 0 => {}
384 + _ => return Err(AppError::BadRequest(
385 + "key_cap (> 0) is required when enforcement_mode = per_key".to_string(),
386 + )),
387 + }
388 + match gb_per_key {
389 + Some(v) if v > 0 => {}
390 + _ => return Err(AppError::BadRequest(
391 + "gb_per_key (> 0) is required when enforcement_mode = per_key".to_string(),
392 + )),
393 + }
394 + if storage_gb_cap.is_some() {
374 395 return Err(AppError::BadRequest(
375 - "key_cap must be omitted when enforcement_mode = app_wide".to_string(),
396 + "storage_gb_cap must be omitted when enforcement_mode = per_key".to_string(),
376 397 ));
377 398 }
378 399 }
379 400 other => {
380 401 return Err(AppError::BadRequest(format!(
381 - "enforcement_mode must be 'per_key' or 'app_wide', got {other:?}"
402 + "enforcement_mode must be 'bulk' or 'per_key', got {other:?}"
382 403 )));
383 404 }
384 405 }
@@ -207,7 +207,9 @@ pub(super) async fn blob_download_url(
207 207 .await?
208 208 .ok_or(AppError::NotFound)?;
209 209
210 - // Billing + egress cap enforcement (internal apps bypass).
210 + // Billing check (internal apps bypass). Egress is NOT enforced — it's a
211 + // free metric for the developer's dashboard, absorbed in the storage rate
212 + // margin. We still count it at presign time so devs see the stat.
211 213 let billing = synckit_billing::get_app_with_billing(&state.db, sync_user.app_id)
212 214 .await?
213 215 .ok_or(AppError::NotFound)?;
@@ -219,23 +221,10 @@ pub(super) async fn blob_download_url(
219 221 )
220 222 .into_response());
221 223 }
222 - if let Some(exceeded) = synckit_billing::would_exceed_egress(
223 - &state.db, sync_user.app_id, blob.size_bytes,
224 - ).await? {
225 - return Ok((
226 - StatusCode::PAYMENT_REQUIRED,
227 - Json(json!({
228 - "reason": "egress_limit_reached",
229 - "used": exceeded.used,
230 - "limit": exceeded.limit,
231 - })),
232 - )
233 - .into_response());
234 - }
235 224 // Count egress optimistically at presign time. The client may not
236 - // actually download (especially if a retry hits dedup-cached content),
237 - // so this overcounts slightly. Trade-off: simpler than streaming
238 - // bytes-out from S3 access logs, and conservative on the cap side.
225 + // actually download (retries that hit dedup-cached content, for
226 + // example), so this overcounts slightly. Acceptable for a free
227 + // dashboard metric.
239 228 if let Err(e) = synckit_billing::add_bytes_egress(
240 229 &state.db, sync_user.app_id, blob.size_bytes,
241 230 ).await {
@@ -299,14 +299,19 @@ pub(crate) struct BlobDownloadUrlResponse {
299 299 // ── Developer billing types ──
300 300
301 301 /// Request body for `POST /api/sync/apps/{id}/billing/activate`. Knob shape
302 - /// matches the columns added in migration 117. `key_cap` is required iff
303 - /// `enforcement_mode = "per_key"`.
302 + /// matches the columns after migration 118.
303 + ///
304 + /// In `enforcement_mode = "bulk"`: `storage_gb_cap` is required; `key_cap` and
305 + /// `gb_per_key` must be omitted.
306 + ///
307 + /// In `enforcement_mode = "per_key"`: `key_cap` AND `gb_per_key` are required;
308 + /// `storage_gb_cap` must be omitted.
304 309 #[derive(Deserialize)]
305 310 pub(crate) struct BillingActivateRequest {
306 - pub storage_gb_cap: u32,
307 - pub egress_multiple: f64,
308 311 pub enforcement_mode: String,
312 + pub storage_gb_cap: Option<u32>,
309 313 pub key_cap: Option<u32>,
314 + pub gb_per_key: Option<u32>,
310 315 }
311 316
312 317 /// Request body for `PATCH /api/sync/apps/{id}/billing`; same shape as
@@ -335,11 +340,13 @@ pub(crate) struct BillingStatusResponse {
335 340 pub app_id: SyncAppId,
336 341 pub billing_status: String,
337 342 pub is_internal: bool,
338 - pub storage_gb_cap: Option<u32>,
339 - pub egress_multiple: Option<f64>,
340 343 pub enforcement_mode: String,
344 + pub storage_gb_cap: Option<u32>,
341 345 pub key_cap: Option<u32>,
346 + pub gb_per_key: Option<u32>,
342 347 pub bytes_stored: i64,
348 + /// Egress in the current billing period. Tracked for developer-facing
349 + /// stats only; egress is NOT a price input and NOT enforced as a cap.
343 350 pub bytes_egress_period: i64,
344 351 pub keys_claimed: u32,
345 352 pub last_warning_pct: u8,
@@ -1,55 +1,60 @@
1 1 //! SyncKit developer billing: pricing formula and constants.
2 2 //!
3 - //! See migration 117 for the full pricing model. Summary:
3 + //! Two modes:
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 + //! bulk — price = storage_gb_cap × $0.03
6 + //! per_key — price = key_cap × gb_per_key × $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 + //! Both are pure GB-based pricing. Egress and ingress are absorbed by the
9 + //! storage rate's ~2× margin against Cloudflare R2 ($0.015/GB) where SyncKit
10 + //! blobs are hosted.
11 + //!
12 + //! Invoices are floored at a Stripe-fee-cover threshold so we never lose money
13 + //! on a transaction. See `BASE_FLOOR_CENTS` for the math.
19 14
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;
15 + /// Storage rate in cents per GB per month. Calibrated to ~2× R2 cost
16 + /// ($0.015/GB storage, $0 egress on R2). The 2× margin spread absorbs any
17 + /// ingress/egress cost variance, so we don't need a separate egress price.
18 + pub const STORAGE_RATE_CENTS_PER_GB: f64 = 3.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 + /// Stripe-fee-cover floor in cents. Stripe charges 2.9% + $0.30 per
21 + /// successful charge. We pick the smallest invoice `F` (cents) such that the
22 + /// remainder after Stripe fees is non-negative:
23 + ///
24 + /// F × (1 − 0.029) − 30 ≥ 0 ⇒ F ≥ 30 / 0.971 ⇒ F ≥ 30.9¢
25 + ///
26 + /// Round up to 31¢. At the floor, MNW nets ~$0 — covered, not profitable.
27 + pub const BASE_FLOOR_CENTS: i64 = 31;
25 28
26 - /// Warning thresholds (percent of any cap). Matches CHECK constraint on
27 - /// `sync_app_usage_current.last_warning_pct`.
29 + /// Warning thresholds (percent of storage cap). Matches CHECK constraint on
30 + /// `sync_app_usage_current.last_warning_pct`. Only storage is enforced, so
31 + /// these thresholds apply to storage usage only.
28 32 pub const WARNING_THRESHOLDS_PCT: &[i16] = &[75, 90, 100];
29 33
30 34 /// Compute the monthly Stripe invoice amount in cents for a given knob set.
31 35 ///
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.
36 + /// In bulk mode: `storage_gb_cap` is set, others are `None`.
37 + /// In per_key mode: `key_cap` and `gb_per_key` are set, `storage_gb_cap` is `None`.
38 + ///
39 + /// Floors at `BASE_FLOOR_CENTS` so we never invoice below the Stripe-fee
40 + /// break-even amount.
35 41 pub fn monthly_price_cents(
36 - storage_gb: u32,
37 - egress_multiple: f64,
42 + enforcement_mode: &str,
43 + storage_gb_cap: Option<u32>,
38 44 key_cap: Option<u32>,
45 + gb_per_key: Option<u32>,
39 46 ) -> i64 {
40 - let storage = f64::from(storage_gb) * STORAGE_RATE_CENTS_PER_GB;
41 - let egress_quota_gb = f64::from(storage_gb) * egress_multiple;
42 - 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
47 - }
48 -
49 - /// Egress quota in bytes for the given knobs.
50 - pub fn egress_quota_bytes(storage_gb: u32, egress_multiple: f64) -> i64 {
51 - let gb = f64::from(storage_gb) * egress_multiple;
52 - (gb * 1024.0 * 1024.0 * 1024.0).ceil() as i64
47 + let gb: f64 = match enforcement_mode {
48 + "bulk" => storage_gb_cap.map(f64::from).unwrap_or(0.0),
49 + "per_key" => {
50 + let k = key_cap.map(f64::from).unwrap_or(0.0);
51 + let g = gb_per_key.map(f64::from).unwrap_or(0.0);
52 + k * g
53 + }
54 + _ => 0.0,
55 + };
56 + let raw = (gb * STORAGE_RATE_CENTS_PER_GB).ceil() as i64;
57 + raw.max(BASE_FLOOR_CENTS)
53 58 }
54 59
55 60 /// Storage cap in bytes for the given GB cap.
@@ -61,60 +66,66 @@ pub fn storage_cap_bytes(storage_gb: u32) -> i64 {
61 66 mod tests {
62 67 use super::*;
63 68
64 - fn approx(a: i64, b: i64) -> bool {
65 - (a - b).abs() <= 1
69 + #[test]
70 + fn bulk_mode_pricing() {
71 + // 100 GB bulk → 100 × 3 = 300 cents.
72 + assert_eq!(monthly_price_cents("bulk", Some(100), None, None), 300);
73 + // 1000 GB → $30.
74 + assert_eq!(monthly_price_cents("bulk", Some(1000), None, None), 3000);
66 75 }
67 76
68 77 #[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);
78 + fn per_key_mode_pricing() {
79 + // 50 keys × 2 GB = 100 GB equivalent → 300 cents. Matches 100 GB bulk.
80 + assert_eq!(monthly_price_cents("per_key", None, Some(50), Some(2)), 300);
81 + // 1000 keys × 1 GB → $30.
82 + assert_eq!(monthly_price_cents("per_key", None, Some(1000), Some(1)), 3000);
74 83 }
75 84
76 85 #[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));
86 + fn floor_kicks_in_for_small_accounts() {
87 + // 1 GB bulk → 3¢ raw, floored to 31¢.
88 + assert_eq!(monthly_price_cents("bulk", Some(1), None, None), 31);
89 + // 10 GB → 30¢, also floored to 31¢ (one cent short).
90 + assert_eq!(monthly_price_cents("bulk", Some(10), None, None), 31);
91 + // 11 GB → 33¢, above floor.
92 + assert_eq!(monthly_price_cents("bulk", Some(11), None, None), 33);
93 + // 1 key × 1 GB → 3¢ raw, floored.
94 + assert_eq!(monthly_price_cents("per_key", None, Some(1), Some(1)), 31);
82 95 }
83 96
84 97 #[test]
85 98 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 - let p = monthly_price_cents(10240, 30.0, None);
91 - assert!(p > 80_000 && p < 82_000, "got {p}");
99 + // 10 TB bulk → 10240 × 3 = 30720¢ = $307.20.
100 + assert_eq!(monthly_price_cents("bulk", Some(10_240), None, None), 30_720);
101 + // 10k keys × 1 GB → same.
102 + assert_eq!(monthly_price_cents("per_key", None, Some(10_000), Some(1)), 30_000);
92 103 }
93 104
94 105 #[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));
106 + fn missing_knobs_drop_to_floor() {
107 + // Mode is set but no knobs provided — should hit the floor.
108 + assert_eq!(monthly_price_cents("bulk", None, None, None), BASE_FLOOR_CENTS);
109 + assert_eq!(monthly_price_cents("per_key", None, None, None), BASE_FLOOR_CENTS);
99 110 }
100 111
101 112 #[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}");
113 + fn unknown_mode_drops_to_floor() {
114 + // Defensive: an unrecognized mode shouldn't blow up; it lands at the floor.
115 + assert_eq!(monthly_price_cents("unknown", Some(100), None, None), BASE_FLOOR_CENTS);
106 116 }
107 117
108 118 #[test]
109 - 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);
119 + fn floor_amount_covers_stripe_fee() {
120 + // 31¢ × 0.971 = 30.10¢, minus 30¢ fixed fee = 0.10¢ net. Verifies the
121 + // documented math: the floor covers Stripe's fee with ~0 margin.
122 + let net = (BASE_FLOOR_CENTS as f64) * 0.971 - 30.0;
123 + assert!(net >= 0.0, "floor must net ≥ 0 after Stripe fees, got {net}");
124 + assert!(net < 1.0, "floor should be tight, not overshoot — got {net}");
112 125 }
113 126
114 127 #[test]
115 - fn quota_calc_in_bytes() {
116 - // 10 GB at 3× = 30 GB egress quota.
117 - assert_eq!(egress_quota_bytes(10, 3.0), 30 * 1024 * 1024 * 1024);
128 + fn storage_cap_in_bytes() {
118 129 assert_eq!(storage_cap_bytes(10), 10 * 1024 * 1024 * 1024);
119 130 }
120 131 }
@@ -493,15 +493,23 @@ fn gauge_tier(pct: i32) -> &'static str {
493 493 impl super::dashboard::SyncAppBillingView {
494 494 pub fn from_db(b: &db::DbSyncAppBilling) -> Self {
495 495 use crate::helpers::format_bytes;
496 - use crate::synckit_billing::{
497 - egress_quota_bytes, monthly_price_cents, storage_cap_bytes,
498 - };
496 + use crate::synckit_billing::{monthly_price_cents, storage_cap_bytes};
499 497
500 498 let bytes_stored = b.bytes_stored.unwrap_or(0);
501 499 let bytes_egress_period = b.bytes_egress_period.unwrap_or(0);
502 500 let keys_claimed = b.keys_claimed.unwrap_or(0);
503 501
504 - let (storage_pct, storage_display) = match b.storage_gb_cap {
502 + // Effective storage cap (GB): depends on enforcement_mode.
503 + let effective_gb: Option<i32> = match b.enforcement_mode.as_str() {
504 + "bulk" => b.storage_gb_cap,
505 + "per_key" => match (b.key_cap, b.gb_per_key) {
506 + (Some(k), Some(g)) => Some(k.saturating_mul(g)),
507 + _ => None,
508 + },
509 + _ => None,
510 + };
511 +
512 + let (storage_pct, storage_display) = match effective_gb {
505 513 Some(gb) => {
506 514 let limit = storage_cap_bytes(gb as u32);
507 515 let pct = if limit > 0 {
@@ -517,21 +525,8 @@ impl super::dashboard::SyncAppBillingView {
517 525 None => (0, format_bytes(bytes_stored)),
518 526 };
519 527
520 - let (egress_pct, egress_display) = match (b.storage_gb_cap, b.egress_multiple) {
521 - (Some(gb), Some(mult)) => {
522 - let limit = egress_quota_bytes(gb as u32, mult);
523 - let pct = if limit > 0 {
524 - ((bytes_egress_period as f64 / limit as f64) * 100.0).round() as i32
525 - } else {
526 - 0
527 - };
528 - (
529 - pct.clamp(0, 200),
530 - format!("{} / {}", format_bytes(bytes_egress_period), format_bytes(limit)),
531 - )
532 - }
533 - _ => (0, format_bytes(bytes_egress_period)),
534 - };
528 + // Egress shown as a free metric (no cap, no percentage).
529 + let egress_display = format!("{} this period", format_bytes(bytes_egress_period));
535 530
536 531 let keys_pct = match b.key_cap {
537 532 Some(cap) if cap > 0 => {
@@ -545,16 +540,20 @@ impl super::dashboard::SyncAppBillingView {
545 540 .map(|t| format!("Resets {}", t.format("%b %d")))
546 541 .unwrap_or_default();
547 542
548 - let price_display = match (b.storage_gb_cap, b.egress_multiple) {
549 - (Some(gb), Some(mult)) => {
550 - let cents = monthly_price_cents(
551 - gb as u32,
552 - mult,
553 - b.key_cap.map(|c| c as u32),
554 - );
555 - format!("${}.{:02} / month", cents / 100, cents % 100)
556 - }
557 - _ => String::new(),
543 + let knobs_set = matches!(b.enforcement_mode.as_str(),
544 + "bulk" if b.storage_gb_cap.is_some())
545 + || matches!(b.enforcement_mode.as_str(),
546 + "per_key" if b.key_cap.is_some() && b.gb_per_key.is_some());
547 + let price_display = if knobs_set {
548 + let cents = monthly_price_cents(
549 + &b.enforcement_mode,
550 + b.storage_gb_cap.map(|v| v as u32),
551 + b.key_cap.map(|v| v as u32),
552 + b.gb_per_key.map(|v| v as u32),
553 + );
554 + format!("${}.{:02} / month", cents / 100, cents % 100)
555 + } else {
556 + String::new()
558 557 };
559 558
560 559 let keys_pct = keys_pct.clamp(0, 200);
@@ -563,18 +562,22 @@ impl super::dashboard::SyncAppBillingView {
563 562 status: b.billing_status.clone(),
564 563 is_internal: b.is_internal,
565 564 has_customer: b.stripe_customer_id.is_some(),
565 + // Default to bulk for drafts so the panel renders sensibly even
566 + // when enforcement_mode hasn't been settled by the dev yet.
567 + enforcement_mode: if b.enforcement_mode.is_empty() {
568 + "bulk".to_string()
569 + } else {
570 + b.enforcement_mode.clone()
571 + },
566 572 storage_gb_cap: b.storage_gb_cap,
567 - egress_multiple: b.egress_multiple,
568 - enforcement_mode: b.enforcement_mode.clone(),
569 573 key_cap: b.key_cap,
574 + gb_per_key: b.gb_per_key,
570 575 bytes_stored,
571 576 bytes_egress_period,
572 577 keys_claimed,
573 578 storage_pct,
574 - egress_pct,
575 579 keys_pct,
576 580 storage_tier: gauge_tier(storage_pct),
577 - egress_tier: gauge_tier(egress_pct),
578 581 keys_tier: gauge_tier(keys_pct),
579 582 storage_display,
580 583 egress_display,
@@ -582,7 +585,8 @@ impl super::dashboard::SyncAppBillingView {
582 585 price_display,
583 586 // Defaults shown in the knob picker before the developer touches it.
584 587 default_storage_gb: b.storage_gb_cap.unwrap_or(10),
585 - default_egress_multiple: b.egress_multiple.unwrap_or(3.0),
588 + default_key_cap: b.key_cap.unwrap_or(100),
589 + default_gb_per_key: b.gb_per_key.unwrap_or(1),
586 590 }
587 591 }
588 592 }
@@ -109,36 +109,35 @@ pub struct SyncAppBillingView {
109 109 pub is_internal: bool,
110 110 /// Stripe customer exists — setup step has been completed.
111 111 pub has_customer: bool,
112 - pub storage_gb_cap: Option<i32>,
113 - pub egress_multiple: Option<f64>,
114 - /// 'per_key' | 'app_wide' (defaults to 'app_wide' for drafts).
112 + /// 'per_key' | 'bulk' (defaults to 'bulk' for drafts).
115 113 pub enforcement_mode: String,
114 + pub storage_gb_cap: Option<i32>,
116 115 pub key_cap: Option<i32>,
116 + pub gb_per_key: Option<i32>,
117 117 pub bytes_stored: i64,
118 + /// Egress this period — surfaced as a free dashboard metric only.
118 119 pub bytes_egress_period: i64,
119 120 pub keys_claimed: i32,
120 121 /// Storage usage as a percentage of cap (0-200, clamped at 200 for display).
121 122 pub storage_pct: i32,
122 - /// Egress usage as a percentage of period quota.
123 - pub egress_pct: i32,
124 123 /// Keys claimed as a percentage of cap (per_key only).
125 124 pub keys_pct: i32,
126 - /// Color tier for each gauge — `""`, `"warn"`, `"danger"` based on the
127 - /// 75%/90%/100% thresholds in `synckit_billing::WARNING_THRESHOLDS_PCT`.
125 + /// Color tier for the storage gauge — `""`, `"warn"`, `"danger"` based on
126 + /// the 75%/90%/100% thresholds in `WARNING_THRESHOLDS_PCT`.
128 127 pub storage_tier: &'static str,
129 - pub egress_tier: &'static str,
130 128 pub keys_tier: &'static str,
131 129 /// Human-readable storage usage, e.g. "1.2 GB / 10 GB".
132 130 pub storage_display: String,
133 - /// Human-readable egress usage for the current period.
131 + /// Human-readable egress this period (no cap shown — not enforced).
134 132 pub egress_display: String,
135 133 /// "Resets May 28" or similar; empty when no period set.
136 134 pub period_end_display: String,
137 - /// "$5.00 / month" when knobs are set; empty in draft.
135 + /// "$0.31 / month" when knobs are set; empty in draft.
138 136 pub price_display: String,
139 137 /// Default knob values used to pre-populate the picker on a draft app.
140 138 pub default_storage_gb: i32,
141 - pub default_egress_multiple: f64,
139 + pub default_key_cap: i32,
140 + pub default_gb_per_key: i32,
142 141 }
143 142
144 143 /// A contact (buyer who shared their email) for the contacts dashboard tab.
@@ -1,4 +1,4 @@
1 - /* SyncKit billing panel — slider/textbox knob picker + live price preview +
1 + /* SyncKit billing panel — mode toggle + live price preview +
2 2 setup / activate / patch / cancel / portal flows.
3 3
4 4 Loaded globally from base.html. The user/project SyncKit dashboard tab
@@ -6,9 +6,8 @@
6 6 function is idempotent: a panel that's already wired up gets skipped via
7 7 the `data-wired` marker.
8 8
9 - Pricing constants are read from data-* attrs on each .synckit-billing root
10 - to keep them in sync with src/synckit_billing.rs without server-rendered
11 - inline JS. The formula must mirror monthly_price_cents() in that file.
9 + Pricing formula constants come from data-* attrs on each .synckit-billing
10 + root. The formula mirrors src/synckit_billing.rs::monthly_price_cents.
12 11 */
13 12 (function () {
14 13 'use strict';
@@ -19,54 +18,75 @@
19 18 return '$' + dollars + '.' + (rem < 10 ? '0' : '') + rem;
20 19 }
21 20
22 - function priceCents(panel, storageGb, egressMult, keyCap) {
23 - var sRate = parseFloat(panel.dataset.storageRate);
24 - var eRate = parseFloat(panel.dataset.egressRate);
25 - var kRate = parseFloat(panel.dataset.keyRate);
21 + function priceCents(panel, mode, knobs) {
22 + var rate = parseFloat(panel.dataset.storageRate);
26 23 var floor = parseInt(panel.dataset.baseFloorCents, 10);
27 - var storage = storageGb * sRate;
28 - 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));
24 + var gb = 0;
25 + if (mode === 'bulk') {
26 + gb = knobs.storage_gb_cap || 0;
27 + } else if (mode === 'per_key') {
28 + gb = (knobs.key_cap || 0) * (knobs.gb_per_key || 0);
29 + }
30 + var raw = Math.ceil(gb * rate);
31 + return Math.max(raw, floor);
32 + }
33 +
34 + function selectedMode(panel) {
35 + var checked = panel.querySelector('input[name^="synckit-mode-"]:checked');
36 + return checked ? checked.value : 'bulk';
32 37 }
33 38
34 39 function readKnobs(panel) {
35 - var storage = parseInt(panel.querySelector('.synckit-storage-input').value, 10) || 0;
36 - var egress = parseFloat(panel.querySelector('.synckit-egress-input').value) || 0;
37 - var modeEl = panel.querySelector('input[name^="synckit-mode-"]:checked');
38 - var mode = modeEl ? modeEl.value : 'app_wide';
39 - var keyCap = null;
40 - if (mode === 'per_key') {
41 - keyCap = parseInt(panel.querySelector('.synckit-key-cap-input').value, 10) || 0;
40 + return {
41 + storage_gb_cap: parseInt(panel.querySelector('.synckit-storage-input').value, 10) || 0,
42 + key_cap: parseInt(panel.querySelector('.synckit-key-cap-input').value, 10) || 0,
43 + gb_per_key: parseInt(panel.querySelector('.synckit-gb-per-key-input').value, 10) || 0,
44 + };
45 + }
46 +
47 + function buildRequestBody(panel) {
48 + var mode = selectedMode(panel);
49 + var knobs = readKnobs(panel);
50 + var body = { enforcement_mode: mode };
51 + if (mode === 'bulk') {
52 + body.storage_gb_cap = knobs.storage_gb_cap;
53 + } else if (mode === 'per_key') {
54 + body.key_cap = knobs.key_cap;
55 + body.gb_per_key = knobs.gb_per_key;
42 56 }
43 - return { storage_gb_cap: storage, egress_multiple: egress, enforcement_mode: mode, key_cap: keyCap };
57 + return body;
44 58 }
45 59
46 - function validateKnobs(knobs) {
47 - if (!(knobs.storage_gb_cap > 0)) return 'Storage cap must be greater than 0.';
48 - if (!(knobs.egress_multiple > 0)) return 'Egress multiple must be greater than 0.';
49 - if (knobs.enforcement_mode === 'per_key' && !(knobs.key_cap > 0)) {
50 - return 'Key cap is required for per-key enforcement.';
60 + function validateBody(body) {
61 + if (body.enforcement_mode === 'bulk') {
62 + if (!(body.storage_gb_cap > 0)) return 'Storage must be greater than 0 GB.';
63 + } else if (body.enforcement_mode === 'per_key') {
64 + if (!(body.key_cap > 0)) return 'Key cap must be greater than 0.';
65 + if (!(body.gb_per_key > 0)) return 'GB per key must be greater than 0.';
51 66 }
52 67 return null;
53 68 }
54 69
55 70 function updatePricePreview(panel) {
56 - var k = readKnobs(panel);
57 - var cents = priceCents(panel, k.storage_gb_cap, k.egress_multiple, k.key_cap);
71 + var mode = selectedMode(panel);
72 + var knobs = readKnobs(panel);
73 + var cents = priceCents(panel, mode, knobs);
58 74 var preview = panel.querySelector('.synckit-price-preview');
59 75 if (preview) preview.textContent = moneyFromCents(cents) + ' / month';
60 76 }
61 77
78 + function setRowsForMode(panel, mode) {
79 + var bulkRows = panel.querySelectorAll('.synckit-bulk-row');
80 + var perKeyRows = panel.querySelectorAll('.synckit-per-key-row');
81 + Array.prototype.forEach.call(bulkRows, function (r) { r.hidden = (mode !== 'bulk'); });
82 + Array.prototype.forEach.call(perKeyRows, function (r) { r.hidden = (mode !== 'per_key'); });
83 + }
84 +
62 85 function syncSliderTextbox(slider, textbox) {
63 86 slider.addEventListener('input', function () { textbox.value = slider.value; });
64 87 textbox.addEventListener('input', function () {
65 88 var v = parseFloat(textbox.value);
66 89 if (!isNaN(v)) {
67 - // Clamp into slider range only when within the slider's bounds —
68 - // otherwise let the textbox carry an out-of-range value (the
69 - // slider sits at its max but the dev can type a larger number).
70 90 var min = parseFloat(slider.min);
71 91 var max = parseFloat(slider.max);
72 92 slider.value = String(Math.min(Math.max(v, min), max));
@@ -128,33 +148,25 @@
128 148 var appId = panel.dataset.appId;
129 149 var storageSlider = panel.querySelector('.synckit-storage-slider');
130 150 var storageInput = panel.querySelector('.synckit-storage-input');
131 - var egressSlider = panel.querySelector('.synckit-egress-slider');
132 - var egressInput = panel.querySelector('.synckit-egress-input');
133 - var keyCapRow = panel.querySelector('.synckit-key-cap-row');
134 - var keyCapInput = panel.querySelector('.synckit-key-cap-input');
135 - var modeRadios = panel.querySelectorAll('input[name^="synckit-mode-"]');
136 -
137 151 if (storageSlider && storageInput) syncSliderTextbox(storageSlider, storageInput);
138 - if (egressSlider && egressInput) syncSliderTextbox(egressSlider, egressInput);
139 152
140 153 function onKnobChange() {
141 154 updatePricePreview(panel);
142 155 markDirty(panel);
143 156 }
144 157 ['.synckit-storage-input', '.synckit-storage-slider',
145 - '.synckit-egress-input', '.synckit-egress-slider',
146 - '.synckit-key-cap-input'].forEach(function (sel) {
158 + '.synckit-key-cap-input', '.synckit-gb-per-key-input'].forEach(function (sel) {
147 159 var el = panel.querySelector(sel);
148 160 if (el) el.addEventListener('input', onKnobChange);
149 161 });
150 162
163 + var modeRadios = panel.querySelectorAll('input[name^="synckit-mode-"]');
151 164 Array.prototype.forEach.call(modeRadios, function (radio) {
152 165 radio.addEventListener('change', function () {
153 - if (keyCapRow) {
154 - if (radio.value === 'per_key' && radio.checked) keyCapRow.hidden = false;
155 - if (radio.value === 'app_wide' && radio.checked) keyCapRow.hidden = true;
166 + if (radio.checked) {
167 + setRowsForMode(panel, radio.value);
168 + onKnobChange();
156 169 }
157 - onKnobChange();
158 170 });
159 171 });
160 172
@@ -192,12 +204,12 @@
192 204 var activateBtn = panel.querySelector('.synckit-billing-activate-btn');
193 205 if (activateBtn) {
194 206 activateBtn.addEventListener('click', function () {
195 - var knobs = readKnobs(panel);
196 - var err = validateKnobs(knobs);
207 + var body = buildRequestBody(panel);
208 + var err = validateBody(body);
197 209 if (err) { setStatusMsg(panel, err, 'error'); return; }
198 210 activateBtn.disabled = true;
199 211 setStatusMsg(panel, 'Activating…', 'info');
200 - postJson('/api/sync/apps/' + appId + '/billing/activate', knobs).then(function (res) {
212 + postJson('/api/sync/apps/' + appId + '/billing/activate', body).then(function (res) {
201 213 if (!res.ok) {
202 214 return res.text().then(function (t) {
203 215 activateBtn.disabled = false;
@@ -216,12 +228,12 @@
216 228 var saveBtn = panel.querySelector('.synckit-billing-save-btn');
217 229 if (saveBtn) {
218 230 saveBtn.addEventListener('click', function () {
219 - var knobs = readKnobs(panel);
220 - var err = validateKnobs(knobs);
231 + var body = buildRequestBody(panel);
232 + var err = validateBody(body);
221 233 if (err) { setStatusMsg(panel, err, 'error'); return; }
222 234 saveBtn.disabled = true;
223 235 setStatusMsg(panel, 'Saving…', 'info');
224 - patchJson('/api/sync/apps/' + appId + '/billing', knobs).then(function (res) {
236 + patchJson('/api/sync/apps/' + appId + '/billing', body).then(function (res) {
225 237 if (!res.ok) {
226 238 return res.text().then(function (t) {
227 239 saveBtn.disabled = false;
@@ -236,7 +248,7 @@
236 248 });
237 249 }
238 250
239 - // ── Portal (re-open Stripe customer portal for payment method updates) ──
251 + // ── Portal ──
240 252 var portalBtn = panel.querySelector('.synckit-billing-portal-btn');
241 253 if (portalBtn) {
242 254 portalBtn.addEventListener('click', function () {
@@ -288,7 +300,6 @@
288 300 Array.prototype.forEach.call(panels, wirePanel);
289 301 };
290 302
291 - // Auto-init on initial page load.
292 303 if (document.readyState === 'loading') {
293 304 document.addEventListener('DOMContentLoaded', window.initSyncKitBilling);
294 305 } else {
@@ -2,10 +2,13 @@
2 2
3 3 Parent context must expose `app: SyncAppRow` with `app.billing: Option<SyncAppBillingView>`.
4 4
5 - All knob, status, and pricing-constant values are emitted as data-* attrs on the
6 - root .synckit-billing form so static/synckit-billing.js can drive the slider/textbox
7 - picker and live price preview without re-parsing the DOM. The price formula constants
8 - must stay in sync with src/synckit_billing.rs.
5 + Two pricing modes (driven by the `enforcement_mode` radio):
6 + bulk — developer sets storage_gb_cap. Price = storage_gb_cap × rate.
7 + per_key — developer sets key_cap + gb_per_key. Price = key_cap × gb_per_key × rate.
8 +
9 + Pricing-formula constants live on data-* attrs on the .synckit-billing root
10 + so static/synckit-billing.js can mirror monthly_price_cents() without
11 + duplicating values. They must stay in sync with src/synckit_billing.rs.
9 12 #}
10 13 {% if let Some(b) = app.billing %}
11 14 <details class="synckit-billing" data-app-id="{{ app.id }}"
@@ -14,12 +17,10 @@
14 17 data-has-customer="{{ b.has_customer }}"
15 18 data-enforcement-mode="{{ b.enforcement_mode }}"
16 19 data-storage-gb="{{ b.default_storage_gb }}"
17 - data-egress-multiple="{{ b.default_egress_multiple }}"
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">
20 + data-key-cap="{{ b.default_key_cap }}"
21 + data-gb-per-key="{{ b.default_gb_per_key }}"
22 + data-storage-rate="3"
23 + data-base-floor-cents="31">
23 24 <summary class="synckit-billing-summary">
24 25 <span class="synckit-billing-status synckit-billing-status--{{ b.status }}">
25 26 {% 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,31 +50,11 @@
49 50 {% if b.has_customer && b.status != "canceled" %}
50 51 <form class="synckit-billing-form synckit-billing-section">
51 52 <div class="synckit-knob-row">
52 - <label for="synckit-storage-{{ app.id }}">Storage cap</label>
53 - <input type="range" id="synckit-storage-{{ app.id }}"
54 - class="synckit-storage-slider" min="1" max="10240" step="1"
55 - value="{{ b.default_storage_gb }}">
56 - <input type="number" class="synckit-storage-input input--sm w-100"
57 - min="1" step="1" value="{{ b.default_storage_gb }}">
58 - <span class="form-hint">GB</span>
59 - </div>
60 -
61 - <div class="synckit-knob-row">
62 - <label for="synckit-egress-{{ app.id }}">Egress quota</label>
63 - <input type="range" id="synckit-egress-{{ app.id }}"
64 - class="synckit-egress-slider" min="0.5" max="30" step="0.5"
65 - value="{{ b.default_egress_multiple }}">
66 - <input type="number" class="synckit-egress-input input--sm w-100"
67 - min="0.1" step="0.1" value="{{ b.default_egress_multiple }}">
68 - <span class="form-hint">× storage / month</span>
69 - </div>
70 -
71 - <div class="synckit-knob-row">
72 - <label>Enforcement</label>
53 + <label>Pricing mode</label>
73 54 <label class="synckit-radio">
74 - <input type="radio" name="synckit-mode-{{ app.id }}" value="app_wide"
75 - {% if b.enforcement_mode == "app_wide" %}checked{% endif %}>
76 - App-wide cutoff
55 + <input type="radio" name="synckit-mode-{{ app.id }}" value="bulk"
56 + {% if b.enforcement_mode == "bulk" %}checked{% endif %}>
57 + Bulk
77 58 </label>
78 59 <label class="synckit-radio">
79 60 <input type="radio" name="synckit-mode-{{ app.id }}" value="per_key"
@@ -82,18 +63,36 @@
82 63 </label>
83 64 </div>
84 65
85 - <div class="synckit-knob-row synckit-key-cap-row"
66 + <div class="synckit-knob-row synckit-bulk-row"
67 + {% if b.enforcement_mode != "bulk" %}hidden{% endif %}>
68 + <label for="synckit-storage-{{ app.id }}">Storage</label>
69 + <input type="range" id="synckit-storage-{{ app.id }}"
70 + class="synckit-storage-slider" min="1" max="10240" step="1"
71 + value="{{ b.default_storage_gb }}">
72 + <input type="number" class="synckit-storage-input input--sm w-100"
73 + min="1" step="1" value="{{ b.default_storage_gb }}">
74 + <span class="form-hint">GB total</span>
75 + </div>
76 +
77 + <div class="synckit-knob-row synckit-per-key-row"
86 78 {% if b.enforcement_mode != "per_key" %}hidden{% endif %}>
87 79 <label>Key cap</label>
88 80 <input type="number" class="synckit-key-cap-input input--sm w-100"
89 - min="1" step="1"
90 - value="{% if let Some(c) = b.key_cap %}{{ c }}{% else %}100{% endif %}">
91 - <span class="form-hint">active keys</span>
81 + min="1" step="1" value="{{ b.default_key_cap }}">
82 + <span class="form-hint">max active keys</span>
83 + </div>
84 +
85 + <div class="synckit-knob-row synckit-per-key-row"
86 + {% if b.enforcement_mode != "per_key" %}hidden{% endif %}>
87 + <label>GB per key</label>
88 + <input type="number" class="synckit-gb-per-key-input input--sm w-100"
89 + min="1" step="1" value="{{ b.default_gb_per_key }}">
90 + <span class="form-hint">storage allotment per key</span>
92 91 </div>
93 92
94 93 <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>
94 + <span class="synckit-price-preview">$0.31</span>
95 + <span class="form-hint">/ month — $0.03/GB, floored at $0.31 (Stripe fee)</span>
97 96 </div>
98 97
99 98 <div class="synckit-billing-actions">
@@ -107,7 +106,7 @@
107 106 </form>
108 107 {% endif %}
109 108
110 - {# Usage gauges: shown for any non-draft, non-canceled app. #}
109 + {# Usage gauges (storage enforced; egress and keys shown as stats). #}
111 110 {% if b.status == "active" || b.status == "suspended_unpaid" %}
112 111 <div class="synckit-billing-section">
113 112 <h3 class="synckit-billing-subheading">Usage this period</h3>
@@ -123,15 +122,9 @@
123 122 </div>
124 123 </div>
125 124
126 - <div class="synckit-gauge">
127 - <div class="synckit-gauge-label">
128 - <span>Egress</span>
129 - <span class="synckit-gauge-value">{{ b.egress_display }}</span>
130 - </div>
131 - <div class="progress-bar-container progress-bar-container--slim progress-bar-container--rounded">
132 - <div class="progress-bar synckit-gauge-fill{% if !b.egress_tier.is_empty() %} synckit-gauge-fill--{{ b.egress_tier }}{% endif %}"
133 - style="width: {{ b.egress_pct }}%"></div>
134 - </div>
125 + <div class="synckit-stat">
126 + <span>Egress</span>
127 + <span class="synckit-gauge-value">{{ b.egress_display }}</span>
135 128 </div>
136 129
137 130 {% if b.enforcement_mode == "per_key" %}
@@ -27,10 +27,10 @@ struct BillingUpdatedResp {
27 27 struct BillingStatusResp {
28 28 billing_status: String,
29 29 is_internal: bool,
30 - storage_gb_cap: Option<u32>,
31 - egress_multiple: Option<f64>,
32 30 enforcement_mode: String,
31 + storage_gb_cap: Option<u32>,
33 32 key_cap: Option<u32>,
33 + gb_per_key: Option<u32>,
34 34 monthly_price_cents: Option<i64>,
35 35 }
36 36
@@ -103,20 +103,19 @@ 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 in bulk mode at 100 GB. Price = 100 × $0.03 = $3.00.
107 107 let resp = h.client.post_json(
108 108 &format!("/api/sync/apps/{}/billing/activate", app_id),
109 109 &json!({
110 - "storage_gb_cap": 10,
111 - "egress_multiple": 3.0,
112 - "enforcement_mode": "app_wide"
110 + "enforcement_mode": "bulk",
111 + "storage_gb_cap": 100
113 112 }).to_string(),
114 113 ).await;
115 114 assert_eq!(resp.status, 200, "activate failed: {}", resp.text);
116 115
117 116 let body: BillingUpdatedResp = resp.json();
118 117 assert_eq!(body.billing_status, "active");
119 - assert_eq!(body.monthly_price_cents, 500, "10 GB / 3× should hit the $5 floor");
118 + assert_eq!(body.monthly_price_cents, 300, "100 GB bulk should be $3.00");
120 119 assert!(body.stripe_subscription_id.is_some());
121 120
122 121 // GET should agree
@@ -125,11 +124,39 @@ async fn activate_then_get_reports_active_status_and_price() {
125 124 let status: BillingStatusResp = resp.json();
126 125 assert_eq!(status.billing_status, "active");
127 126 assert!(!status.is_internal);
128 - assert_eq!(status.storage_gb_cap, Some(10));
129 - assert_eq!(status.egress_multiple, Some(3.0));
130 - assert_eq!(status.enforcement_mode, "app_wide");
127 + assert_eq!(status.enforcement_mode, "bulk");
128 + assert_eq!(status.storage_gb_cap, Some(100));
131 129 assert_eq!(status.key_cap, None);
132 - assert_eq!(status.monthly_price_cents, Some(500));
130 + assert_eq!(status.gb_per_key, None);
131 + assert_eq!(status.monthly_price_cents, Some(300));
132 + }
133 +
134 + #[tokio::test]
135 + async fn activate_per_key_mode() {
136 + let mut h = TestHarness::with_mocks().await;
137 + let user_id = h.signup("dev2pk", "dev2pk@example.com", "Password1!").await;
138 + let (app_id, _) = create_draft_app(&h.db, user_id).await;
139 + h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await;
140 +
141 + // 50 keys × 2 GB = 100 GB equivalent → $3.00.
142 + let resp = h.client.post_json(
143 + &format!("/api/sync/apps/{}/billing/activate", app_id),
144 + &json!({
145 + "enforcement_mode": "per_key",
146 + "key_cap": 50,
147 + "gb_per_key": 2
148 + }).to_string(),
149 + ).await;
150 + assert_eq!(resp.status, 200, "activate failed: {}", resp.text);
151 + let body: BillingUpdatedResp = resp.json();
152 + assert_eq!(body.monthly_price_cents, 300);
153 +
154 + let resp = h.client.get(&format!("/api/sync/apps/{}/billing", app_id)).await;
155 + let status: BillingStatusResp = resp.json();
156 + assert_eq!(status.enforcement_mode, "per_key");
157 + assert_eq!(status.storage_gb_cap, None);
158 + assert_eq!(status.key_cap, Some(50));
159 + assert_eq!(status.gb_per_key, Some(2));
133 160 }
134 161
135 162 #[tokio::test]
@@ -141,19 +168,17 @@ async fn patch_reprices_subscription() {
141 168 h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await;
142 169 h.client.post_json(
143 170 &format!("/api/sync/apps/{}/billing/activate", app_id),
144 - &json!({ "storage_gb_cap": 10, "egress_multiple": 3.0, "enforcement_mode": "app_wide" }).to_string(),
171 + &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(),
145 172 ).await;
146 173
147 - // PATCH up to a workload that exceeds the floor.
174 + // PATCH up to 1000 GB → $30.00.
148 175 let resp = h.client.patch_json(
149 176 &format!("/api/sync/apps/{}/billing", app_id),
150 - &json!({ "storage_gb_cap": 500, "egress_multiple": 5.0, "enforcement_mode": "app_wide" }).to_string(),
177 + &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 1000 }).to_string(),
151 178 ).await;
152 179 assert_eq!(resp.status, 200, "patch failed: {}", resp.text);
153 180 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);
181 + assert_eq!(body.monthly_price_cents, 3000);
157 182 }
158 183
159 184 #[tokio::test]
@@ -165,7 +190,7 @@ async fn cancel_returns_no_content_and_marks_canceled() {
165 190 h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await;
166 191 h.client.post_json(
167 192 &format!("/api/sync/apps/{}/billing/activate", app_id),
168 - &json!({ "storage_gb_cap": 10, "egress_multiple": 3.0, "enforcement_mode": "app_wide" }).to_string(),
193 + &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(),
169 194 ).await;
170 195
171 196 let resp = h.client.delete(&format!("/api/sync/apps/{}/billing", app_id)).await;
@@ -191,16 +216,30 @@ async fn activate_rejects_invalid_knobs() {
191 216 // per_key without key_cap → 400.
192 217 let resp = h.client.post_json(
193 218 &format!("/api/sync/apps/{}/billing/activate", app_id),
194 - &json!({ "storage_gb_cap": 10, "egress_multiple": 3.0, "enforcement_mode": "per_key" }).to_string(),
219 + &json!({ "enforcement_mode": "per_key", "gb_per_key": 1 }).to_string(),
195 220 ).await;
196 221 assert_eq!(resp.status, 400, "expected 400 for missing key_cap: {}", resp.text);
197 222
198 - // storage_gb_cap = 0 → 400.
223 + // per_key without gb_per_key → 400.
224 + let resp = h.client.post_json(
225 + &format!("/api/sync/apps/{}/billing/activate", app_id),
226 + &json!({ "enforcement_mode": "per_key", "key_cap": 10 }).to_string(),
227 + ).await;
228 + assert_eq!(resp.status, 400, "expected 400 for missing gb_per_key: {}", resp.text);
229 +
230 + // bulk with storage_gb_cap = 0 → 400.
199 231 let resp = h.client.post_json(
200 232 &format!("/api/sync/apps/{}/billing/activate", app_id),
201 - &json!({ "storage_gb_cap": 0, "egress_multiple": 3.0, "enforcement_mode": "app_wide" }).to_string(),
233 + &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 0 }).to_string(),
202 234 ).await;
203 235 assert_eq!(resp.status, 400, "expected 400 for zero storage_gb_cap: {}", resp.text);
236 +
237 + // bulk with extra knobs → 400.
238 + let resp = h.client.post_json(
239 + &format!("/api/sync/apps/{}/billing/activate", app_id),
240 + &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 10, "key_cap": 5 }).to_string(),
241 + ).await;
242 + assert_eq!(resp.status, 400, "expected 400 for mixing modes: {}", resp.text);
204 243 }
205 244
206 245 #[tokio::test]
@@ -229,10 +268,9 @@ async fn claim_key_blocked_at_cap_in_per_key_mode() {
229 268 let resp = h.client.post_json(
230 269 &format!("/api/sync/apps/{}/billing/activate", app_id),
231 270 &json!({
232 - "storage_gb_cap": 10,
233 - "egress_multiple": 3.0,
234 271 "enforcement_mode": "per_key",
235 - "key_cap": 2
272 + "key_cap": 2,
273 + "gb_per_key": 1
236 274 }).to_string(),
237 275 ).await;
238 276 assert_eq!(resp.status, 200, "activate failed: {}", resp.text);