Skip to content

g1t/services/billing/src/pricing.rs

803 lines34,183 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1//! The price book as versions, and how it changes.
2//!
3//! A price is its cost plus the markup, and costs move: Cloudflare's
4//! rates, how much CPU sandboxes use, what Artifacts turns out to count as
5//! an operation. So prices must move too, without surprising anyone and
6//! without g1t selling at a loss.
7//!
8//! - **Versions.** Every price is a row in `price_versions`, never changed
9//! once written. `prices` holds the version in force; a version waiting
10//! for its date is applied by the daily run, and recorded in
11//! `price_changes` (the public record on the pricing page). A charge
12//! records the version it was made at (`ledger.price_version`), so what a
13//! past statement says is always explained by the prices of then.
14//! - **Proposals.** The keeper and the reconciler measure costs; what they
15//! find is proposed (`price_proposals`). A move under 2% is noise. One
16//! within the guardrail (`auto_apply_percent`, 25%) is applied on its own
17//! when `auto_apply` is on. Anything larger, or more than four times off
18//! (suspect), waits for staff to approve or reject in sudo.
19//! - **Notice.** A fall applies at once: customers only gain. A rise
20//! applies `notice_days` (14) after it is decided, and a monthly meter's
21//! at the start of the month after that, so no month is charged at two
22//! prices. Owners of workspaces on the plan are emailed once per rise.
23//! Cost-plus means the rise does come: margin protection is for new usage
24//! after the notice, never retroactive.
25
26use g1t_contracts::billing::*;
27use g1t_contracts::time::{parse_rfc3339, rfc3339};
28use g1t_contracts::{FailureCode, Outcome, new_id};
29use g1t_kit::now_ms;
30use serde::Deserialize;
31use serde_json::Value;
32use worker::Result;
33
34use crate::Billing;
35
36/// Smaller moves are noise.
37pub(crate) const MIN_CHANGE: f64 = 0.02;
38/// A measurement outside this factor of the current cost is suspect: it
39/// is proposed for a person to look at, never applied on its own.
40pub(crate) const MAX_FACTOR: f64 = 4.0;
41
42/// Meters charged once a month from the month's total (`storage`). A rise
43/// in one takes effect at the start of a month.
44pub(crate) const MONTHLY: [&str; 7] = ["git_operations", "private_storage", "actions_cache", "custom_domain_month", "embedding_tokens", "scan_cpu", "scan_rows"];
45
46/// The price meters a month-end source is charged at, for the ledger's
47/// `price_version`.
48pub(crate) fn meters_of(source: &str) -> &'static [&'static str] {
49 match source {
50 "git" => &["git_operations"],
51 "storage" => &["private_storage"],
52 "context" => &["embedding_tokens"],
53 "security" => &["scan_cpu", "scan_rows"],
54 "domains" => &["custom_domain_month"],
55 "cache" => &["actions_cache"],
56 _ => &[],
57 }
58}
59
60/// Rises smaller than this, in percent, are listed on the pricing page
61/// with their date but not emailed: the keeper moves sandbox seconds by a
62/// percent or two at a time, and an email for each would be noise.
63const EMAIL_RISE_PERCENT: f64 = 5.0;
64
65/// Owners told of rises per run, at most; the rest on the next run.
66const NOTICES_PER_RUN: usize = 40;
67
68/// What may change prices without a person.
69#[derive(Clone, Copy, Debug, PartialEq)]
70pub(crate) struct Guard {
71 pub auto_apply: bool,
72 /// The largest move applied on its own, in percent either way.
73 pub auto_percent: f64,
74 /// Days between deciding a rise and charging it.
75 pub notice_days: u32,
76}
77
78impl From<&CostSettings> for Guard {
79 fn from(s: &CostSettings) -> Self {
80 Guard { auto_apply: s.auto_apply, auto_percent: s.auto_apply_percent, notice_days: s.notice_days }
81 }
82}
83
84#[derive(Clone, Debug, PartialEq)]
85pub(crate) enum Decision {
86 /// Too small to matter.
87 Nothing,
88 /// Applied without a person, from `effective_ms`.
89 Auto { effective_ms: u64 },
90 /// Waits for staff. `suspect` when the measurement is wildly off.
91 Approve { suspect: bool },
92}
93
94/// When a new cost takes effect: a fall at once; a rise after the notice
95/// period, and for a monthly meter at the start of the first month after
96/// it.
97pub(crate) fn effective_ms(current: f64, new: f64, now_ms: u64, notice_days: u32, monthly: bool) -> u64 {
98 if new <= current {
99 return now_ms;
100 }
101 let after = now_ms + u64::from(notice_days) * crate::costs::DAY_MS;
102 if !monthly {
103 return after;
104 }
105 let stamp = rfc3339(after);
106 if &stamp[8..] == "01T00:00:00.000Z" {
107 return after;
108 }
109 parse_rfc3339(&crate::credits::next_month_start(&stamp[..7])).unwrap_or(after)
110}
111
112/// What to do with a measured cost against the one in force (or the one
113/// already scheduled).
114pub(crate) fn decide(current: f64, measured: f64, guard: Guard, now_ms: u64, monthly: bool) -> Decision {
115 if !measured.is_finite() || measured <= 0.0 || !current.is_finite() || current <= 0.0 {
116 return Decision::Nothing;
117 }
118 let ratio = measured / current;
119 if (ratio - 1.0).abs() < MIN_CHANGE {
120 return Decision::Nothing;
121 }
122 if !(1.0 / MAX_FACTOR..=MAX_FACTOR).contains(&ratio) {
123 return Decision::Approve { suspect: true };
124 }
125 if guard.auto_apply && (ratio - 1.0).abs() * 100.0 <= guard.auto_percent {
126 return Decision::Auto { effective_ms: effective_ms(current, measured, now_ms, guard.notice_days, monthly) };
127 }
128 Decision::Approve { suspect: false }
129}
130
131/// A version of one meter's price.
132#[derive(Clone, Debug, PartialEq, Deserialize)]
133pub(crate) struct Version {
134 pub id: String,
135 pub meter: String,
136 pub version: u32,
137 pub cost_micros: f64,
138 pub markup_percent: u32,
139 pub effective_at: String,
140 pub reason: String,
141 pub created_by: String,
142 pub applied_at: Option<String>,
143}
144
145/// The version in force for `meter` at `at`: the newest whose date has
146/// come. Old versions never change, so a past month's charges are always
147/// explained by the version of then.
148pub(crate) fn in_force<'a>(versions: &'a [Version], meter: &str, at: &str) -> Option<&'a Version> {
149 versions
150 .iter()
151 .filter(|v| v.meter == meter && v.effective_at.as_str() <= at)
152 .max_by(|a, b| a.effective_at.cmp(&b.effective_at).then(a.version.cmp(&b.version)))
153}
154
155/// Settings from `cost_settings` rows, defaults for anything missing or
156/// unreadable.
157pub(crate) fn settings_from(rows: &[(String, String)]) -> CostSettings {
158 let mut s = CostSettings::default();
159 for (key, value) in rows {
160 let value = value.trim();
161 match key.as_str() {
162 "auto_apply" => s.auto_apply = value == "true",
163 "auto_apply_percent" => s.auto_apply_percent = value.parse().unwrap_or(s.auto_apply_percent),
164 "notice_days" => s.notice_days = value.parse().unwrap_or(s.notice_days),
165 "margin_floor_percent" => s.margin_floor_percent = value.parse().unwrap_or(s.margin_floor_percent),
166 "alert_days" => s.alert_days = value.parse().unwrap_or(s.alert_days),
167 "min_daily_cost_micros" => s.min_daily_cost_micros = value.parse().unwrap_or(s.min_daily_cost_micros),
168 "anomaly_factor" => s.anomaly_factor = value.parse().unwrap_or(s.anomaly_factor),
169 "anomaly_floor_micros" => s.anomaly_floor_micros = value.parse().unwrap_or(s.anomaly_floor_micros),
Usage, Billing settings and prepaid AI credit; fixes from the UX audit170 "card_fee" => s.card_fee = value != "off",
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily171 _ => {}
172 }
173 }
174 s
175}
176
177/// Why settings cannot be saved, if they cannot.
178pub(crate) fn check_settings(s: &CostSettings) -> std::result::Result<(), String> {
179 if !(0.0..=100.0).contains(&s.auto_apply_percent) {
180 return Err("The guardrail is a percentage from 0 to 100.".into());
181 }
182 if s.notice_days > 90 {
183 return Err("Notice is at most 90 days.".into());
184 }
185 if !(-100.0..=100.0).contains(&s.margin_floor_percent) {
186 return Err("The margin floor is a percentage.".into());
187 }
188 if s.alert_days == 0 || s.alert_days > 30 {
189 return Err("Alert after 1 to 30 days.".into());
190 }
191 if s.min_daily_cost_micros < 0 || s.anomaly_floor_micros < 0 || !(s.anomaly_factor > 0.0 && s.anomaly_factor.is_finite()) {
192 return Err("Amounts and the factor must be positive.".into());
193 }
194 Ok(())
195}
196
197#[derive(Deserialize)]
198struct ProposalRow {
199 id: String,
200 meter: String,
201 current_cost_micros: f64,
202 proposed_cost_micros: f64,
203 reason: String,
204 source: String,
205 suspect: i64,
206 status: String,
207 created_at: String,
208 decided_at: Option<String>,
209 decided_by: Option<String>,
210 note: Option<String>,
211 version_id: Option<String>,
212 title: Option<String>,
213 unit: Option<String>,
214 markup_percent: Option<u32>,
215 effective_at: Option<String>,
216}
217
218impl From<ProposalRow> for PriceProposal {
219 fn from(r: ProposalRow) -> Self {
220 let change = if r.current_cost_micros > 0.0 { (r.proposed_cost_micros / r.current_cost_micros - 1.0) * 100.0 } else { 0.0 };
221 PriceProposal {
222 title: r.title.unwrap_or_else(|| r.meter.clone()),
223 unit: r.unit.unwrap_or_default(),
224 markup_percent: r.markup_percent.unwrap_or(20),
225 id: r.id,
226 meter: r.meter,
227 current_cost_micros: r.current_cost_micros,
228 proposed_cost_micros: r.proposed_cost_micros,
229 change_percent: change,
230 reason: r.reason,
231 source: r.source,
232 suspect: r.suspect != 0,
233 status: r.status,
234 created_at: r.created_at,
235 decided_at: r.decided_at,
236 decided_by: r.decided_by,
237 note: r.note,
238 effective_at: r.version_id.and(r.effective_at),
239 }
240 }
241}
242
243const PROPOSAL_SQL: &str = "SELECT p.*, pr.title, pr.unit, pr.markup_percent, v.effective_at
244 FROM price_proposals p
245 LEFT JOIN prices pr ON pr.meter = p.meter
246 LEFT JOIN price_versions v ON v.id = p.version_id";
247
248impl Billing {
249 pub(crate) async fn cost_settings(&self) -> Result<CostSettings> {
250 #[derive(Deserialize)]
251 struct Row {
252 key: String,
253 value: String,
254 }
255 let rows = self.db.prepare("SELECT key, value FROM cost_settings").all().await?.results::<Row>()?;
256 Ok(settings_from(&rows.into_iter().map(|r| (r.key, r.value)).collect::<Vec<_>>()))
257 }
258
259 pub(crate) async fn admin_set_cost_settings(&self, a: AdminSetCostSettingsArgs) -> Result<Outcome<CostSettings>> {
260 if let Err(why) = check_settings(&a.settings) {
261 return Ok(Outcome::fail(FailureCode::Invalid, why));
262 }
263 let s = &a.settings;
264 let now = rfc3339(now_ms());
265 let pairs = [
266 ("auto_apply", s.auto_apply.to_string()),
267 ("auto_apply_percent", s.auto_apply_percent.to_string()),
268 ("notice_days", s.notice_days.to_string()),
269 ("margin_floor_percent", s.margin_floor_percent.to_string()),
270 ("alert_days", s.alert_days.to_string()),
271 ("min_daily_cost_micros", s.min_daily_cost_micros.to_string()),
272 ("anomaly_factor", s.anomaly_factor.to_string()),
273 ("anomaly_floor_micros", s.anomaly_floor_micros.to_string()),
Usage, Billing settings and prepaid AI credit; fixes from the UX audit274 ("card_fee", if s.card_fee { "on" } else { "off" }.to_owned()),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily275 ];
276 let mut statements = Vec::new();
277 for (key, value) in &pairs {
278 statements.push(
279 self.db
280 .prepare(
281 "INSERT INTO cost_settings (key, value, updated_at, updated_by) VALUES (?1, ?2, ?3, ?4)
282 ON CONFLICT (key) DO UPDATE SET value = ?2, updated_at = ?3, updated_by = ?4",
283 )
284 .bind(&[(*key).into(), value.as_str().into(), now.as_str().into(), a.by.as_str().into()])?,
285 );
286 }
287 self.db.batch(statements).await?;
288 let detail = pairs.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join(", ");
289 self.audit("costs", "cost_settings", &detail, &a.by).await?;
290 Ok(Outcome::Ok(self.cost_settings().await?))
291 }
292
293 /// The cost a new measurement is judged against: the newest version
294 /// scheduled, else the one in force.
295 async fn latest_cost(&self, meter: &str) -> Result<Option<(f64, u32)>> {
296 #[derive(Deserialize)]
297 struct Row {
298 cost_micros: f64,
299 markup_percent: u32,
300 }
301 let scheduled = self
302 .db
303 .prepare("SELECT cost_micros, markup_percent FROM price_versions WHERE meter = ? AND applied_at IS NULL ORDER BY version DESC LIMIT 1")
304 .bind(&[meter.into()])?
305 .first::<Row>(None)
306 .await?;
307 if let Some(row) = scheduled {
308 return Ok(Some((row.cost_micros, row.markup_percent)));
309 }
310 Ok(self
311 .db
312 .prepare("SELECT cost_micros, markup_percent FROM prices WHERE meter = ?")
313 .bind(&[meter.into()])?
314 .first::<Row>(None)
315 .await?
316 .map(|r| (r.cost_micros, r.markup_percent)))
317 }
318
319 /// Proposes a measured cost for a meter. Returns what happened, in
320 /// words, or None when there was nothing to do.
321 pub(crate) async fn propose(&self, meter: &str, measured: f64, reason: &str, source: &str) -> Result<Option<String>> {
322 let Some((current, markup)) = self.latest_cost(meter).await? else {
323 return Ok(None);
324 };
325 // Ten-thousandths of a micro are plenty; floating-point tails are not.
326 let measured = (measured * 10_000.0).round() / 10_000.0;
327 let settings = self.cost_settings().await?;
328 let now = now_ms();
329 let decision = decide(current, measured, Guard::from(&settings), now, MONTHLY.contains(&meter));
330 if decision == Decision::Nothing {
331 return Ok(None);
332 }
333 let stamp = rfc3339(now);
334 let id = new_id("ppr", now);
335 // A newer measurement replaces an open one.
336 self.db
337 .prepare("UPDATE price_proposals SET status = 'superseded', decided_at = ? WHERE meter = ? AND status = 'open'")
338 .bind(&[stamp.as_str().into(), meter.into()])?
339 .run()
340 .await?;
341 let (status, suspect) = match decision {
342 Decision::Auto { .. } => ("applied", false),
343 Decision::Approve { suspect } => ("open", suspect),
344 Decision::Nothing => unreachable!(),
345 };
346 self.db
347 .prepare(
348 "INSERT INTO price_proposals (id, meter, current_cost_micros, proposed_cost_micros, reason, source, suspect, status, created_at, decided_at, decided_by)
349 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
350 )
351 .bind(&[
352 id.as_str().into(),
353 meter.into(),
354 current.into(),
355 measured.into(),
356 reason.into(),
357 source.into(),
358 i32::from(suspect).into(),
359 status.into(),
360 stamp.as_str().into(),
361 crate::optional((status == "applied").then_some(stamp.as_str())),
362 crate::optional((status == "applied").then_some("guardrail")),
363 ])?
364 .run()
365 .await?;
366 if status == "open" {
367 return Ok(Some(format!(
368 "proposed {measured:.4} against {current:.4}{}, waiting for staff",
369 if suspect { " (far off: look before approving)" } else { "" }
370 )));
371 }
372 let Decision::Auto { effective_ms } = decision else { unreachable!() };
373 let version = self.schedule_version(meter, measured, markup, effective_ms, reason, source, Some(&id)).await?;
374 self.apply_due_versions().await?;
375 Ok(Some(format!("applied {measured:.4} (was {current:.4}) from {} as {version}", rfc3339(effective_ms))))
376 }
377
378 /// Writes the next version of a meter's price, to take effect at
379 /// `effective_ms`. Returns its id.
380 #[allow(clippy::too_many_arguments)]
381 pub(crate) async fn schedule_version(
382 &self,
383 meter: &str,
384 cost: f64,
385 markup: u32,
386 effective_ms: u64,
387 reason: &str,
388 by: &str,
389 proposal: Option<&str>,
390 ) -> Result<String> {
391 #[derive(Deserialize)]
392 struct Top {
393 version: Option<u32>,
394 }
395 let next = self
396 .db
397 .prepare("SELECT MAX(version) AS version FROM price_versions WHERE meter = ?")
398 .bind(&[meter.into()])?
399 .first::<Top>(None)
400 .await?
401 .and_then(|t| t.version)
402 .unwrap_or(0)
403 + 1;
404 let id = format!("pv_{meter}_{next}");
405 let now = rfc3339(now_ms());
406 // A newer decision replaces a rise still waiting for its date.
407 self.db
408 .prepare("DELETE FROM price_versions WHERE meter = ? AND applied_at IS NULL")
409 .bind(&[meter.into()])?
410 .run()
411 .await?;
412 self.db
413 .prepare(
414 "INSERT INTO price_versions (id, meter, version, cost_micros, markup_percent, effective_at, reason, created_by, proposal_id, created_at)
415 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
416 )
417 .bind(&[
418 id.as_str().into(),
419 meter.into(),
420 next.into(),
421 cost.into(),
422 markup.into(),
423 rfc3339(effective_ms).into(),
424 reason.into(),
425 by.into(),
426 crate::optional(proposal),
427 now.as_str().into(),
428 ])?
429 .run()
430 .await?;
431 if let Some(proposal) = proposal {
432 self.db
433 .prepare("UPDATE price_proposals SET version_id = ? WHERE id = ?")
434 .bind(&[id.as_str().into(), proposal.into()])?
435 .run()
436 .await?;
437 }
438 Ok(id)
439 }
440
441 /// Puts every version whose date has come into the price book, oldest
442 /// first, each once, with a public record of the change.
443 pub(crate) async fn apply_due_versions(&self) -> Result<u32> {
444 let now = rfc3339(now_ms());
445 let due = self
446 .db
447 .prepare("SELECT * FROM price_versions WHERE applied_at IS NULL AND effective_at <= ? ORDER BY effective_at, version")
448 .bind(&[now.as_str().into()])?
449 .all()
450 .await?
451 .results::<Version>()?;
452 let mut applied = 0;
453 for v in due {
454 let claimed = self
455 .db
456 .prepare("UPDATE price_versions SET applied_at = ? WHERE id = ? AND applied_at IS NULL RETURNING id")
457 .bind(&[now.as_str().into(), v.id.as_str().into()])?
458 .first::<Value>(None)
459 .await?;
460 if claimed.is_none() {
461 continue;
462 }
463 let reason = if v.created_by.contains('@') { format!("{} (approved by g1t staff)", v.reason) } else { v.reason.clone() };
464 self.db
465 .batch(vec![
466 self.db
467 .prepare(
468 "INSERT INTO price_changes (id, meter, old_cost_micros, new_cost_micros, markup_percent, old_markup_percent, reason, created_at)
469 SELECT ?, meter, cost_micros, ?, ?, CASE WHEN markup_percent <> ? THEN markup_percent END, ?, ? FROM prices WHERE meter = ?",
470 )
471 .bind(&[
472 new_id("prc", now_ms()).into(),
473 v.cost_micros.into(),
474 v.markup_percent.into(),
475 v.markup_percent.into(),
476 reason.as_str().into(),
477 now.as_str().into(),
478 v.meter.as_str().into(),
479 ])?,
480 self.db
481 .prepare("UPDATE prices SET cost_micros = ?, markup_percent = ?, source = 'cloudflare', updated_at = ? WHERE meter = ?")
482 .bind(&[v.cost_micros.into(), v.markup_percent.into(), now.as_str().into(), v.meter.as_str().into()])?,
483 ])
484 .await?;
485 applied += 1;
486 }
487 Ok(applied)
488 }
489
490 /// The id of the price version in force for `meter` now, for the
491 /// ledger.
492 pub(crate) async fn version_now(&self, meter: &str) -> Result<Option<String>> {
493 let versions = self
494 .db
495 .prepare("SELECT * FROM price_versions WHERE meter = ? AND applied_at IS NOT NULL")
496 .bind(&[meter.into()])?
497 .all()
498 .await?
499 .results::<Version>()?;
500 Ok(in_force(&versions, meter, &rfc3339(now_ms())).map(|v| v.id.clone()))
501 }
502
503 pub(crate) async fn proposals(&self) -> Result<Vec<PriceProposal>> {
504 Ok(self
505 .db
506 .prepare(format!(
507 "{PROPOSAL_SQL} ORDER BY CASE WHEN p.status = 'open' THEN 0 ELSE 1 END, p.created_at DESC LIMIT 40"
508 ))
509 .all()
510 .await?
511 .results::<ProposalRow>()?
512 .into_iter()
513 .map(PriceProposal::from)
514 .collect())
515 }
516
517 pub(crate) async fn versions(&self) -> Result<Vec<PriceVersion>> {
518 Ok(self
519 .db
520 .prepare("SELECT * FROM price_versions ORDER BY CASE WHEN applied_at IS NULL THEN 0 ELSE 1 END, effective_at DESC, version DESC LIMIT 60")
521 .all()
522 .await?
523 .results::<Version>()?
524 .into_iter()
525 .map(|v| PriceVersion {
526 price_micros: Price::price_for(v.cost_micros, v.markup_percent),
527 id: v.id,
528 meter: v.meter,
529 version: v.version,
530 cost_micros: v.cost_micros,
531 markup_percent: v.markup_percent,
532 effective_at: v.effective_at,
533 reason: v.reason,
534 created_by: v.created_by,
535 applied_at: v.applied_at,
536 })
537 .collect())
538 }
539
540 /// Prices not in force yet, for the pricing page's "coming" changes.
541 pub(crate) async fn coming_changes(&self) -> Result<Vec<PriceChange>> {
542 #[derive(Deserialize)]
543 struct Row {
544 meter: String,
545 old_cost_micros: Option<f64>,
546 cost_micros: f64,
547 markup_percent: u32,
548 reason: String,
549 created_at: String,
550 effective_at: String,
551 }
552 Ok(self
553 .db
554 .prepare(
555 "SELECT v.meter, p.cost_micros AS old_cost_micros, v.cost_micros, v.markup_percent, v.reason, v.created_at, v.effective_at
556 FROM price_versions v LEFT JOIN prices p ON p.meter = v.meter
557 WHERE v.applied_at IS NULL ORDER BY v.effective_at",
558 )
559 .all()
560 .await?
561 .results::<Row>()?
562 .into_iter()
563 .map(|r| PriceChange {
564 old_cost_micros: r.old_cost_micros.unwrap_or(r.cost_micros),
565 meter: r.meter,
566 new_cost_micros: r.cost_micros,
567 markup_percent: r.markup_percent,
568 old_markup_percent: None,
569 reason: r.reason,
570 created_at: r.created_at,
571 effective_at: Some(r.effective_at),
572 })
573 .collect())
574 }
575
576 /// `admin_decide_proposal`: an approved fall applies now; an approved
577 /// rise after the notice period.
578 pub(crate) async fn admin_decide_proposal(&self, a: AdminDecideProposalArgs) -> Result<Outcome<PriceProposal>> {
579 let found = self
580 .db
581 .prepare(format!("{PROPOSAL_SQL} WHERE p.id = ?"))
582 .bind(&[a.id.as_str().into()])?
583 .first::<ProposalRow>(None)
584 .await?;
585 let Some(found) = found else {
586 return Ok(Outcome::fail(FailureCode::NotFound, "No such proposal."));
587 };
588 if found.status != "open" {
589 return Ok(Outcome::fail(FailureCode::Conflict, format!("This proposal is already {}.", found.status)));
590 }
591 let approve = match a.decision.as_str() {
592 "approve" => true,
593 "reject" => false,
594 _ => return Ok(Outcome::fail(FailureCode::Invalid, "Approve or reject.")),
595 };
596 if !approve && a.note.trim().is_empty() {
597 return Ok(Outcome::fail(FailureCode::Invalid, "Say why it is rejected, for whoever measures it next."));
598 }
599 let now = now_ms();
600 let stamp = rfc3339(now);
601 self.db
602 .prepare("UPDATE price_proposals SET status = ?, decided_at = ?, decided_by = ?, note = ? WHERE id = ? AND status = 'open'")
603 .bind(&[
604 if approve { "approved" } else { "rejected" }.into(),
605 stamp.as_str().into(),
606 a.by.as_str().into(),
607 a.note.trim().into(),
608 a.id.as_str().into(),
609 ])?
610 .run()
611 .await?;
612 if approve {
613 let settings = self.cost_settings().await?;
614 let (current, markup) = self.latest_cost(&found.meter).await?.unwrap_or((found.current_cost_micros, 20));
615 let effective = effective_ms(current, found.proposed_cost_micros, now, settings.notice_days, MONTHLY.contains(&found.meter.as_str()));
616 self.schedule_version(&found.meter, found.proposed_cost_micros, markup, effective, &found.reason, &a.by, Some(&found.id))
617 .await?;
618 self.apply_due_versions().await?;
619 }
620 self.audit(
621 "costs",
622 if approve { "price_approved" } else { "price_rejected" },
623 &format!("{}: {:.4} to {:.4}. {}", found.meter, found.current_cost_micros, found.proposed_cost_micros, a.note.trim()),
624 &a.by,
625 )
626 .await?;
627 let row = self
628 .db
629 .prepare(format!("{PROPOSAL_SQL} WHERE p.id = ?"))
630 .bind(&[a.id.as_str().into()])?
631 .first::<ProposalRow>(None)
632 .await?;
633 Ok(match row {
634 Some(row) => Outcome::Ok(row.into()),
635 None => Outcome::fail(FailureCode::NotFound, "No such proposal."),
636 })
637 }
638
639 /// Emails owners of workspaces on the plan about each rise still to
640 /// come, once per rise per workspace.
641 pub(crate) async fn tell_owners_of_rises(&self, identity: &worker::Fetcher) -> Result<()> {
642 #[derive(Deserialize)]
643 struct Rise {
644 id: String,
645 title: Option<String>,
646 old_cost: f64,
647 cost_micros: f64,
648 markup_percent: u32,
649 unit: Option<String>,
650 effective_at: String,
651 reason: String,
652 }
653 let rises = self
654 .db
655 .prepare(
656 "SELECT v.id, p.title, p.cost_micros AS old_cost, v.cost_micros, v.markup_percent, p.unit, v.effective_at, v.reason
657 FROM price_versions v JOIN prices p ON p.meter = v.meter
658 WHERE v.applied_at IS NULL AND v.cost_micros > p.cost_micros * ?",
659 )
660 .bind(&[(1.0 + EMAIL_RISE_PERCENT / 100.0).into()])?
661 .all()
662 .await?
663 .results::<Rise>()?;
664 let mut sent = 0;
665 for rise in rises {
666 #[derive(Deserialize)]
667 struct Workspace {
668 workspace: String,
669 }
670 let workspaces = self
671 .db
672 .prepare(
673 "SELECT DISTINCT workspace FROM subscriptions WHERE feature = 'plan' AND status IN ('active', 'canceling')
674 AND workspace NOT IN (SELECT workspace FROM price_notices WHERE version_id = ?) LIMIT ?",
675 )
676 .bind(&[rise.id.as_str().into(), ((NOTICES_PER_RUN - sent) as u32).into()])?
677 .all()
678 .await?
679 .results::<Workspace>()?;
680 let title = rise.title.clone().unwrap_or_default();
681 let unit = rise.unit.clone().unwrap_or_default();
682 let price = |cost: f64| crate::features::dollars(Price::price_for(cost, rise.markup_percent).round() as i64);
683 let intro = format!(
684 "From {}, {title} on g1t goes from {} to {} per {unit}. It is what g1t pays Cloudflare plus 20%, and the cost moved: {} Nothing already charged changes.",
685 &rise.effective_at[..10],
686 price(rise.old_cost),
687 price(rise.cost_micros),
688 rise.reason
689 );
690 for Workspace { workspace } in workspaces {
691 let args = g1t_contracts::identity::NotifyOwnersArgs {
692 workspace: workspace.clone(),
693 subject: format!("g1t: {title} costs more from {}", &rise.effective_at[..10]),
694 intro: intro.clone(),
695 action: "See prices".to_owned(),
696 link: "https://g1t.sh/pricing".to_owned(),
697 footer: "You get this because you own a workspace on the g1t plan. How prices follow costs: https://docs.g1t.sh/guides/usage-and-billing/#how-prices-are-set".to_owned(),
698 };
699 // Recorded whether or not anyone was there to tell.
700 if let Err(error) = g1t_kit::call::<_, u32>(identity, "notify_owners", &args).await {
701 worker::console_error!("could not tell {workspace} of a price rise: {error}");
702 continue;
703 }
704 self.db
705 .prepare("INSERT OR IGNORE INTO price_notices (version_id, workspace, sent_at) VALUES (?, ?, ?)")
706 .bind(&[rise.id.as_str().into(), workspace.as_str().into(), rfc3339(now_ms()).into()])?
707 .run()
708 .await?;
709 sent += 1;
710 if sent >= NOTICES_PER_RUN {
711 return Ok(());
712 }
713 }
714 }
715 Ok(())
716 }
717}
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722
723 const NOW: &str = "2026-10-06T04:17:00.000Z";
724
725 fn now() -> u64 {
726 parse_rfc3339(NOW).unwrap()
727 }
728
729 fn guard() -> Guard {
730 Guard { auto_apply: true, auto_percent: 25.0, notice_days: 14 }
731 }
732
733 #[test]
734 fn small_moves_are_noise_and_wild_ones_wait_for_a_person() {
735 assert_eq!(decide(21.0, 21.2, guard(), now(), false), Decision::Nothing);
736 assert_eq!(decide(21.0, 0.0, guard(), now(), false), Decision::Nothing);
737 assert_eq!(decide(21.0, 200.0, guard(), now(), false), Decision::Approve { suspect: true });
738 assert_eq!(decide(21.0, 2.0, guard(), now(), false), Decision::Approve { suspect: true });
739 }
740
741 #[test]
742 fn moves_inside_the_guardrail_apply_themselves_falls_at_once_rises_after_notice() {
743 // Down 19%: at once.
744 assert_eq!(decide(21.0, 17.0, guard(), now(), false), Decision::Auto { effective_ms: now() });
745 // Up 19%: after 14 days.
746 let Decision::Auto { effective_ms } = decide(21.0, 25.0, guard(), now(), false) else { panic!() };
747 assert_eq!(rfc3339(effective_ms), "2026-10-20T04:17:00.000Z");
748 // A monthly meter's rise waits for the month after the notice.
749 let Decision::Auto { effective_ms } = decide(150_000.0, 180_000.0, guard(), now(), true) else { panic!() };
750 assert_eq!(rfc3339(effective_ms), "2026-11-01T00:00:00.000Z");
751 let late = parse_rfc3339("2026-10-25T00:00:00Z").unwrap();
752 assert_eq!(rfc3339(effective_ms_for(late)), "2026-12-01T00:00:00.000Z");
753 }
754
755 fn effective_ms_for(now: u64) -> u64 {
756 effective_ms(1.0, 2.0, now, 14, true)
757 }
758
759 #[test]
760 fn past_the_guardrail_or_with_auto_off_staff_decide() {
761 // Up 50%: staff.
762 assert_eq!(decide(150_000.0, 225_000.0, guard(), now(), true), Decision::Approve { suspect: false });
763 // Down 30%: staff too; the guardrail is either way.
764 assert_eq!(decide(100.0, 70.0, guard(), now(), false), Decision::Approve { suspect: false });
765 let off = Guard { auto_apply: false, ..guard() };
766 assert_eq!(decide(21.0, 22.0, off, now(), false), Decision::Approve { suspect: false });
767 }
768
769 #[test]
770 fn a_past_statement_keeps_the_price_of_its_time() {
771 let v = |version: u32, cost: f64, at: &str| Version {
772 id: format!("pv_git_operations_{version}"),
773 meter: "git_operations".into(),
774 version,
775 cost_micros: cost,
776 markup_percent: 20,
777 effective_at: at.into(),
778 reason: String::new(),
779 created_by: "keeper".into(),
780 applied_at: None,
781 };
782 let versions = vec![v(1, 150_000.0, "2026-10-05T00:00:00Z"), v(2, 450_000.0, "2026-11-01T00:00:00.000Z"), v(3, 400_000.0, "2026-12-01T00:00:00.000Z")];
783 // October's charges were at version 1, whatever came later.
784 assert_eq!(in_force(&versions, "git_operations", "2026-10-31T23:59:59Z").unwrap().version, 1);
785 assert_eq!(in_force(&versions, "git_operations", "2026-11-15T00:00:00Z").unwrap().version, 2);
786 assert_eq!(in_force(&versions, "git_operations", "2027-01-01T00:00:00Z").unwrap().cost_micros, 400_000.0);
787 assert!(in_force(&versions, "git_operations", "2026-01-01T00:00:00Z").is_none());
788 assert!(in_force(&versions, "sandbox_second", "2027-01-01T00:00:00Z").is_none());
789 }
790
791 #[test]
792 fn settings_read_with_defaults_and_are_checked() {
793 let s = settings_from(&[("auto_apply".into(), "false".into()), ("notice_days".into(), "30".into()), ("anomaly_factor".into(), "x".into())]);
794 assert!(!s.auto_apply);
795 assert_eq!(s.notice_days, 30);
796 assert_eq!(s.anomaly_factor, 1.0);
797 assert_eq!(s.auto_apply_percent, 25.0);
798 assert!(check_settings(&s).is_ok());
799 assert!(check_settings(&CostSettings { notice_days: 120, ..s.clone() }).is_err());
800 assert!(check_settings(&CostSettings { alert_days: 0, ..s.clone() }).is_err());
801 assert!(check_settings(&CostSettings { auto_apply_percent: 150.0, ..s }).is_err());
802 }
803}