g1t/services/billing/src/pricing.rs

801 lines34,052 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),
170 _ => {}
171 }
172 }
173 s
174}
175
176/// Why settings cannot be saved, if they cannot.
177pub(crate) fn check_settings(s: &CostSettings) -> std::result::Result<(), String> {
178 if !(0.0..=100.0).contains(&s.auto_apply_percent) {
179 return Err("The guardrail is a percentage from 0 to 100.".into());
180 }
181 if s.notice_days > 90 {
182 return Err("Notice is at most 90 days.".into());
183 }
184 if !(-100.0..=100.0).contains(&s.margin_floor_percent) {
185 return Err("The margin floor is a percentage.".into());
186 }
187 if s.alert_days == 0 || s.alert_days > 30 {
188 return Err("Alert after 1 to 30 days.".into());
189 }
190 if s.min_daily_cost_micros < 0 || s.anomaly_floor_micros < 0 || !(s.anomaly_factor > 0.0 && s.anomaly_factor.is_finite()) {
191 return Err("Amounts and the factor must be positive.".into());
192 }
193 Ok(())
194}
195
196#[derive(Deserialize)]
197struct ProposalRow {
198 id: String,
199 meter: String,
200 current_cost_micros: f64,
201 proposed_cost_micros: f64,
202 reason: String,
203 source: String,
204 suspect: i64,
205 status: String,
206 created_at: String,
207 decided_at: Option<String>,
208 decided_by: Option<String>,
209 note: Option<String>,
210 version_id: Option<String>,
211 title: Option<String>,
212 unit: Option<String>,
213 markup_percent: Option<u32>,
214 effective_at: Option<String>,
215}
216
217impl From<ProposalRow> for PriceProposal {
218 fn from(r: ProposalRow) -> Self {
219 let change = if r.current_cost_micros > 0.0 { (r.proposed_cost_micros / r.current_cost_micros - 1.0) * 100.0 } else { 0.0 };
220 PriceProposal {
221 title: r.title.unwrap_or_else(|| r.meter.clone()),
222 unit: r.unit.unwrap_or_default(),
223 markup_percent: r.markup_percent.unwrap_or(20),
224 id: r.id,
225 meter: r.meter,
226 current_cost_micros: r.current_cost_micros,
227 proposed_cost_micros: r.proposed_cost_micros,
228 change_percent: change,
229 reason: r.reason,
230 source: r.source,
231 suspect: r.suspect != 0,
232 status: r.status,
233 created_at: r.created_at,
234 decided_at: r.decided_at,
235 decided_by: r.decided_by,
236 note: r.note,
237 effective_at: r.version_id.and(r.effective_at),
238 }
239 }
240}
241
242const PROPOSAL_SQL: &str = "SELECT p.*, pr.title, pr.unit, pr.markup_percent, v.effective_at
243 FROM price_proposals p
244 LEFT JOIN prices pr ON pr.meter = p.meter
245 LEFT JOIN price_versions v ON v.id = p.version_id";
246
247impl Billing {
248 pub(crate) async fn cost_settings(&self) -> Result<CostSettings> {
249 #[derive(Deserialize)]
250 struct Row {
251 key: String,
252 value: String,
253 }
254 let rows = self.db.prepare("SELECT key, value FROM cost_settings").all().await?.results::<Row>()?;
255 Ok(settings_from(&rows.into_iter().map(|r| (r.key, r.value)).collect::<Vec<_>>()))
256 }
257
258 pub(crate) async fn admin_set_cost_settings(&self, a: AdminSetCostSettingsArgs) -> Result<Outcome<CostSettings>> {
259 if let Err(why) = check_settings(&a.settings) {
260 return Ok(Outcome::fail(FailureCode::Invalid, why));
261 }
262 let s = &a.settings;
263 let now = rfc3339(now_ms());
264 let pairs = [
265 ("auto_apply", s.auto_apply.to_string()),
266 ("auto_apply_percent", s.auto_apply_percent.to_string()),
267 ("notice_days", s.notice_days.to_string()),
268 ("margin_floor_percent", s.margin_floor_percent.to_string()),
269 ("alert_days", s.alert_days.to_string()),
270 ("min_daily_cost_micros", s.min_daily_cost_micros.to_string()),
271 ("anomaly_factor", s.anomaly_factor.to_string()),
272 ("anomaly_floor_micros", s.anomaly_floor_micros.to_string()),
273 ];
274 let mut statements = Vec::new();
275 for (key, value) in &pairs {
276 statements.push(
277 self.db
278 .prepare(
279 "INSERT INTO cost_settings (key, value, updated_at, updated_by) VALUES (?1, ?2, ?3, ?4)
280 ON CONFLICT (key) DO UPDATE SET value = ?2, updated_at = ?3, updated_by = ?4",
281 )
282 .bind(&[(*key).into(), value.as_str().into(), now.as_str().into(), a.by.as_str().into()])?,
283 );
284 }
285 self.db.batch(statements).await?;
286 let detail = pairs.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join(", ");
287 self.audit("costs", "cost_settings", &detail, &a.by).await?;
288 Ok(Outcome::Ok(self.cost_settings().await?))
289 }
290
291 /// The cost a new measurement is judged against: the newest version
292 /// scheduled, else the one in force.
293 async fn latest_cost(&self, meter: &str) -> Result<Option<(f64, u32)>> {
294 #[derive(Deserialize)]
295 struct Row {
296 cost_micros: f64,
297 markup_percent: u32,
298 }
299 let scheduled = self
300 .db
301 .prepare("SELECT cost_micros, markup_percent FROM price_versions WHERE meter = ? AND applied_at IS NULL ORDER BY version DESC LIMIT 1")
302 .bind(&[meter.into()])?
303 .first::<Row>(None)
304 .await?;
305 if let Some(row) = scheduled {
306 return Ok(Some((row.cost_micros, row.markup_percent)));
307 }
308 Ok(self
309 .db
310 .prepare("SELECT cost_micros, markup_percent FROM prices WHERE meter = ?")
311 .bind(&[meter.into()])?
312 .first::<Row>(None)
313 .await?
314 .map(|r| (r.cost_micros, r.markup_percent)))
315 }
316
317 /// Proposes a measured cost for a meter. Returns what happened, in
318 /// words, or None when there was nothing to do.
319 pub(crate) async fn propose(&self, meter: &str, measured: f64, reason: &str, source: &str) -> Result<Option<String>> {
320 let Some((current, markup)) = self.latest_cost(meter).await? else {
321 return Ok(None);
322 };
323 // Ten-thousandths of a micro are plenty; floating-point tails are not.
324 let measured = (measured * 10_000.0).round() / 10_000.0;
325 let settings = self.cost_settings().await?;
326 let now = now_ms();
327 let decision = decide(current, measured, Guard::from(&settings), now, MONTHLY.contains(&meter));
328 if decision == Decision::Nothing {
329 return Ok(None);
330 }
331 let stamp = rfc3339(now);
332 let id = new_id("ppr", now);
333 // A newer measurement replaces an open one.
334 self.db
335 .prepare("UPDATE price_proposals SET status = 'superseded', decided_at = ? WHERE meter = ? AND status = 'open'")
336 .bind(&[stamp.as_str().into(), meter.into()])?
337 .run()
338 .await?;
339 let (status, suspect) = match decision {
340 Decision::Auto { .. } => ("applied", false),
341 Decision::Approve { suspect } => ("open", suspect),
342 Decision::Nothing => unreachable!(),
343 };
344 self.db
345 .prepare(
346 "INSERT INTO price_proposals (id, meter, current_cost_micros, proposed_cost_micros, reason, source, suspect, status, created_at, decided_at, decided_by)
347 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
348 )
349 .bind(&[
350 id.as_str().into(),
351 meter.into(),
352 current.into(),
353 measured.into(),
354 reason.into(),
355 source.into(),
356 i32::from(suspect).into(),
357 status.into(),
358 stamp.as_str().into(),
359 crate::optional((status == "applied").then_some(stamp.as_str())),
360 crate::optional((status == "applied").then_some("guardrail")),
361 ])?
362 .run()
363 .await?;
364 if status == "open" {
365 return Ok(Some(format!(
366 "proposed {measured:.4} against {current:.4}{}, waiting for staff",
367 if suspect { " (far off: look before approving)" } else { "" }
368 )));
369 }
370 let Decision::Auto { effective_ms } = decision else { unreachable!() };
371 let version = self.schedule_version(meter, measured, markup, effective_ms, reason, source, Some(&id)).await?;
372 self.apply_due_versions().await?;
373 Ok(Some(format!("applied {measured:.4} (was {current:.4}) from {} as {version}", rfc3339(effective_ms))))
374 }
375
376 /// Writes the next version of a meter's price, to take effect at
377 /// `effective_ms`. Returns its id.
378 #[allow(clippy::too_many_arguments)]
379 pub(crate) async fn schedule_version(
380 &self,
381 meter: &str,
382 cost: f64,
383 markup: u32,
384 effective_ms: u64,
385 reason: &str,
386 by: &str,
387 proposal: Option<&str>,
388 ) -> Result<String> {
389 #[derive(Deserialize)]
390 struct Top {
391 version: Option<u32>,
392 }
393 let next = self
394 .db
395 .prepare("SELECT MAX(version) AS version FROM price_versions WHERE meter = ?")
396 .bind(&[meter.into()])?
397 .first::<Top>(None)
398 .await?
399 .and_then(|t| t.version)
400 .unwrap_or(0)
401 + 1;
402 let id = format!("pv_{meter}_{next}");
403 let now = rfc3339(now_ms());
404 // A newer decision replaces a rise still waiting for its date.
405 self.db
406 .prepare("DELETE FROM price_versions WHERE meter = ? AND applied_at IS NULL")
407 .bind(&[meter.into()])?
408 .run()
409 .await?;
410 self.db
411 .prepare(
412 "INSERT INTO price_versions (id, meter, version, cost_micros, markup_percent, effective_at, reason, created_by, proposal_id, created_at)
413 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
414 )
415 .bind(&[
416 id.as_str().into(),
417 meter.into(),
418 next.into(),
419 cost.into(),
420 markup.into(),
421 rfc3339(effective_ms).into(),
422 reason.into(),
423 by.into(),
424 crate::optional(proposal),
425 now.as_str().into(),
426 ])?
427 .run()
428 .await?;
429 if let Some(proposal) = proposal {
430 self.db
431 .prepare("UPDATE price_proposals SET version_id = ? WHERE id = ?")
432 .bind(&[id.as_str().into(), proposal.into()])?
433 .run()
434 .await?;
435 }
436 Ok(id)
437 }
438
439 /// Puts every version whose date has come into the price book, oldest
440 /// first, each once, with a public record of the change.
441 pub(crate) async fn apply_due_versions(&self) -> Result<u32> {
442 let now = rfc3339(now_ms());
443 let due = self
444 .db
445 .prepare("SELECT * FROM price_versions WHERE applied_at IS NULL AND effective_at <= ? ORDER BY effective_at, version")
446 .bind(&[now.as_str().into()])?
447 .all()
448 .await?
449 .results::<Version>()?;
450 let mut applied = 0;
451 for v in due {
452 let claimed = self
453 .db
454 .prepare("UPDATE price_versions SET applied_at = ? WHERE id = ? AND applied_at IS NULL RETURNING id")
455 .bind(&[now.as_str().into(), v.id.as_str().into()])?
456 .first::<Value>(None)
457 .await?;
458 if claimed.is_none() {
459 continue;
460 }
461 let reason = if v.created_by.contains('@') { format!("{} (approved by g1t staff)", v.reason) } else { v.reason.clone() };
462 self.db
463 .batch(vec![
464 self.db
465 .prepare(
466 "INSERT INTO price_changes (id, meter, old_cost_micros, new_cost_micros, markup_percent, old_markup_percent, reason, created_at)
467 SELECT ?, meter, cost_micros, ?, ?, CASE WHEN markup_percent <> ? THEN markup_percent END, ?, ? FROM prices WHERE meter = ?",
468 )
469 .bind(&[
470 new_id("prc", now_ms()).into(),
471 v.cost_micros.into(),
472 v.markup_percent.into(),
473 v.markup_percent.into(),
474 reason.as_str().into(),
475 now.as_str().into(),
476 v.meter.as_str().into(),
477 ])?,
478 self.db
479 .prepare("UPDATE prices SET cost_micros = ?, markup_percent = ?, source = 'cloudflare', updated_at = ? WHERE meter = ?")
480 .bind(&[v.cost_micros.into(), v.markup_percent.into(), now.as_str().into(), v.meter.as_str().into()])?,
481 ])
482 .await?;
483 applied += 1;
484 }
485 Ok(applied)
486 }
487
488 /// The id of the price version in force for `meter` now, for the
489 /// ledger.
490 pub(crate) async fn version_now(&self, meter: &str) -> Result<Option<String>> {
491 let versions = self
492 .db
493 .prepare("SELECT * FROM price_versions WHERE meter = ? AND applied_at IS NOT NULL")
494 .bind(&[meter.into()])?
495 .all()
496 .await?
497 .results::<Version>()?;
498 Ok(in_force(&versions, meter, &rfc3339(now_ms())).map(|v| v.id.clone()))
499 }
500
501 pub(crate) async fn proposals(&self) -> Result<Vec<PriceProposal>> {
502 Ok(self
503 .db
504 .prepare(format!(
505 "{PROPOSAL_SQL} ORDER BY CASE WHEN p.status = 'open' THEN 0 ELSE 1 END, p.created_at DESC LIMIT 40"
506 ))
507 .all()
508 .await?
509 .results::<ProposalRow>()?
510 .into_iter()
511 .map(PriceProposal::from)
512 .collect())
513 }
514
515 pub(crate) async fn versions(&self) -> Result<Vec<PriceVersion>> {
516 Ok(self
517 .db
518 .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")
519 .all()
520 .await?
521 .results::<Version>()?
522 .into_iter()
523 .map(|v| PriceVersion {
524 price_micros: Price::price_for(v.cost_micros, v.markup_percent),
525 id: v.id,
526 meter: v.meter,
527 version: v.version,
528 cost_micros: v.cost_micros,
529 markup_percent: v.markup_percent,
530 effective_at: v.effective_at,
531 reason: v.reason,
532 created_by: v.created_by,
533 applied_at: v.applied_at,
534 })
535 .collect())
536 }
537
538 /// Prices not in force yet, for the pricing page's "coming" changes.
539 pub(crate) async fn coming_changes(&self) -> Result<Vec<PriceChange>> {
540 #[derive(Deserialize)]
541 struct Row {
542 meter: String,
543 old_cost_micros: Option<f64>,
544 cost_micros: f64,
545 markup_percent: u32,
546 reason: String,
547 created_at: String,
548 effective_at: String,
549 }
550 Ok(self
551 .db
552 .prepare(
553 "SELECT v.meter, p.cost_micros AS old_cost_micros, v.cost_micros, v.markup_percent, v.reason, v.created_at, v.effective_at
554 FROM price_versions v LEFT JOIN prices p ON p.meter = v.meter
555 WHERE v.applied_at IS NULL ORDER BY v.effective_at",
556 )
557 .all()
558 .await?
559 .results::<Row>()?
560 .into_iter()
561 .map(|r| PriceChange {
562 old_cost_micros: r.old_cost_micros.unwrap_or(r.cost_micros),
563 meter: r.meter,
564 new_cost_micros: r.cost_micros,
565 markup_percent: r.markup_percent,
566 old_markup_percent: None,
567 reason: r.reason,
568 created_at: r.created_at,
569 effective_at: Some(r.effective_at),
570 })
571 .collect())
572 }
573
574 /// `admin_decide_proposal`: an approved fall applies now; an approved
575 /// rise after the notice period.
576 pub(crate) async fn admin_decide_proposal(&self, a: AdminDecideProposalArgs) -> Result<Outcome<PriceProposal>> {
577 let found = self
578 .db
579 .prepare(format!("{PROPOSAL_SQL} WHERE p.id = ?"))
580 .bind(&[a.id.as_str().into()])?
581 .first::<ProposalRow>(None)
582 .await?;
583 let Some(found) = found else {
584 return Ok(Outcome::fail(FailureCode::NotFound, "No such proposal."));
585 };
586 if found.status != "open" {
587 return Ok(Outcome::fail(FailureCode::Conflict, format!("This proposal is already {}.", found.status)));
588 }
589 let approve = match a.decision.as_str() {
590 "approve" => true,
591 "reject" => false,
592 _ => return Ok(Outcome::fail(FailureCode::Invalid, "Approve or reject.")),
593 };
594 if !approve && a.note.trim().is_empty() {
595 return Ok(Outcome::fail(FailureCode::Invalid, "Say why it is rejected, for whoever measures it next."));
596 }
597 let now = now_ms();
598 let stamp = rfc3339(now);
599 self.db
600 .prepare("UPDATE price_proposals SET status = ?, decided_at = ?, decided_by = ?, note = ? WHERE id = ? AND status = 'open'")
601 .bind(&[
602 if approve { "approved" } else { "rejected" }.into(),
603 stamp.as_str().into(),
604 a.by.as_str().into(),
605 a.note.trim().into(),
606 a.id.as_str().into(),
607 ])?
608 .run()
609 .await?;
610 if approve {
611 let settings = self.cost_settings().await?;
612 let (current, markup) = self.latest_cost(&found.meter).await?.unwrap_or((found.current_cost_micros, 20));
613 let effective = effective_ms(current, found.proposed_cost_micros, now, settings.notice_days, MONTHLY.contains(&found.meter.as_str()));
614 self.schedule_version(&found.meter, found.proposed_cost_micros, markup, effective, &found.reason, &a.by, Some(&found.id))
615 .await?;
616 self.apply_due_versions().await?;
617 }
618 self.audit(
619 "costs",
620 if approve { "price_approved" } else { "price_rejected" },
621 &format!("{}: {:.4} to {:.4}. {}", found.meter, found.current_cost_micros, found.proposed_cost_micros, a.note.trim()),
622 &a.by,
623 )
624 .await?;
625 let row = self
626 .db
627 .prepare(format!("{PROPOSAL_SQL} WHERE p.id = ?"))
628 .bind(&[a.id.as_str().into()])?
629 .first::<ProposalRow>(None)
630 .await?;
631 Ok(match row {
632 Some(row) => Outcome::Ok(row.into()),
633 None => Outcome::fail(FailureCode::NotFound, "No such proposal."),
634 })
635 }
636
637 /// Emails owners of workspaces on the plan about each rise still to
638 /// come, once per rise per workspace.
639 pub(crate) async fn tell_owners_of_rises(&self, identity: &worker::Fetcher) -> Result<()> {
640 #[derive(Deserialize)]
641 struct Rise {
642 id: String,
643 title: Option<String>,
644 old_cost: f64,
645 cost_micros: f64,
646 markup_percent: u32,
647 unit: Option<String>,
648 effective_at: String,
649 reason: String,
650 }
651 let rises = self
652 .db
653 .prepare(
654 "SELECT v.id, p.title, p.cost_micros AS old_cost, v.cost_micros, v.markup_percent, p.unit, v.effective_at, v.reason
655 FROM price_versions v JOIN prices p ON p.meter = v.meter
656 WHERE v.applied_at IS NULL AND v.cost_micros > p.cost_micros * ?",
657 )
658 .bind(&[(1.0 + EMAIL_RISE_PERCENT / 100.0).into()])?
659 .all()
660 .await?
661 .results::<Rise>()?;
662 let mut sent = 0;
663 for rise in rises {
664 #[derive(Deserialize)]
665 struct Workspace {
666 workspace: String,
667 }
668 let workspaces = self
669 .db
670 .prepare(
671 "SELECT DISTINCT workspace FROM subscriptions WHERE feature = 'plan' AND status IN ('active', 'canceling')
672 AND workspace NOT IN (SELECT workspace FROM price_notices WHERE version_id = ?) LIMIT ?",
673 )
674 .bind(&[rise.id.as_str().into(), ((NOTICES_PER_RUN - sent) as u32).into()])?
675 .all()
676 .await?
677 .results::<Workspace>()?;
678 let title = rise.title.clone().unwrap_or_default();
679 let unit = rise.unit.clone().unwrap_or_default();
680 let price = |cost: f64| crate::features::dollars(Price::price_for(cost, rise.markup_percent).round() as i64);
681 let intro = format!(
682 "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.",
683 &rise.effective_at[..10],
684 price(rise.old_cost),
685 price(rise.cost_micros),
686 rise.reason
687 );
688 for Workspace { workspace } in workspaces {
689 let args = g1t_contracts::identity::NotifyOwnersArgs {
690 workspace: workspace.clone(),
691 subject: format!("g1t: {title} costs more from {}", &rise.effective_at[..10]),
692 intro: intro.clone(),
693 action: "See prices".to_owned(),
694 link: "https://g1t.sh/pricing".to_owned(),
695 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(),
696 };
697 // Recorded whether or not anyone was there to tell.
698 if let Err(error) = g1t_kit::call::<_, u32>(identity, "notify_owners", &args).await {
699 worker::console_error!("could not tell {workspace} of a price rise: {error}");
700 continue;
701 }
702 self.db
703 .prepare("INSERT OR IGNORE INTO price_notices (version_id, workspace, sent_at) VALUES (?, ?, ?)")
704 .bind(&[rise.id.as_str().into(), workspace.as_str().into(), rfc3339(now_ms()).into()])?
705 .run()
706 .await?;
707 sent += 1;
708 if sent >= NOTICES_PER_RUN {
709 return Ok(());
710 }
711 }
712 }
713 Ok(())
714 }
715}
716
717#[cfg(test)]
718mod tests {
719 use super::*;
720
721 const NOW: &str = "2026-10-06T04:17:00.000Z";
722
723 fn now() -> u64 {
724 parse_rfc3339(NOW).unwrap()
725 }
726
727 fn guard() -> Guard {
728 Guard { auto_apply: true, auto_percent: 25.0, notice_days: 14 }
729 }
730
731 #[test]
732 fn small_moves_are_noise_and_wild_ones_wait_for_a_person() {
733 assert_eq!(decide(21.0, 21.2, guard(), now(), false), Decision::Nothing);
734 assert_eq!(decide(21.0, 0.0, guard(), now(), false), Decision::Nothing);
735 assert_eq!(decide(21.0, 200.0, guard(), now(), false), Decision::Approve { suspect: true });
736 assert_eq!(decide(21.0, 2.0, guard(), now(), false), Decision::Approve { suspect: true });
737 }
738
739 #[test]
740 fn moves_inside_the_guardrail_apply_themselves_falls_at_once_rises_after_notice() {
741 // Down 19%: at once.
742 assert_eq!(decide(21.0, 17.0, guard(), now(), false), Decision::Auto { effective_ms: now() });
743 // Up 19%: after 14 days.
744 let Decision::Auto { effective_ms } = decide(21.0, 25.0, guard(), now(), false) else { panic!() };
745 assert_eq!(rfc3339(effective_ms), "2026-10-20T04:17:00.000Z");
746 // A monthly meter's rise waits for the month after the notice.
747 let Decision::Auto { effective_ms } = decide(150_000.0, 180_000.0, guard(), now(), true) else { panic!() };
748 assert_eq!(rfc3339(effective_ms), "2026-11-01T00:00:00.000Z");
749 let late = parse_rfc3339("2026-10-25T00:00:00Z").unwrap();
750 assert_eq!(rfc3339(effective_ms_for(late)), "2026-12-01T00:00:00.000Z");
751 }
752
753 fn effective_ms_for(now: u64) -> u64 {
754 effective_ms(1.0, 2.0, now, 14, true)
755 }
756
757 #[test]
758 fn past_the_guardrail_or_with_auto_off_staff_decide() {
759 // Up 50%: staff.
760 assert_eq!(decide(150_000.0, 225_000.0, guard(), now(), true), Decision::Approve { suspect: false });
761 // Down 30%: staff too; the guardrail is either way.
762 assert_eq!(decide(100.0, 70.0, guard(), now(), false), Decision::Approve { suspect: false });
763 let off = Guard { auto_apply: false, ..guard() };
764 assert_eq!(decide(21.0, 22.0, off, now(), false), Decision::Approve { suspect: false });
765 }
766
767 #[test]
768 fn a_past_statement_keeps_the_price_of_its_time() {
769 let v = |version: u32, cost: f64, at: &str| Version {
770 id: format!("pv_git_operations_{version}"),
771 meter: "git_operations".into(),
772 version,
773 cost_micros: cost,
774 markup_percent: 20,
775 effective_at: at.into(),
776 reason: String::new(),
777 created_by: "keeper".into(),
778 applied_at: None,
779 };
780 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")];
781 // October's charges were at version 1, whatever came later.
782 assert_eq!(in_force(&versions, "git_operations", "2026-10-31T23:59:59Z").unwrap().version, 1);
783 assert_eq!(in_force(&versions, "git_operations", "2026-11-15T00:00:00Z").unwrap().version, 2);
784 assert_eq!(in_force(&versions, "git_operations", "2027-01-01T00:00:00Z").unwrap().cost_micros, 400_000.0);
785 assert!(in_force(&versions, "git_operations", "2026-01-01T00:00:00Z").is_none());
786 assert!(in_force(&versions, "sandbox_second", "2027-01-01T00:00:00Z").is_none());
787 }
788
789 #[test]
790 fn settings_read_with_defaults_and_are_checked() {
791 let s = settings_from(&[("auto_apply".into(), "false".into()), ("notice_days".into(), "30".into()), ("anomaly_factor".into(), "x".into())]);
792 assert!(!s.auto_apply);
793 assert_eq!(s.notice_days, 30);
794 assert_eq!(s.anomaly_factor, 1.0);
795 assert_eq!(s.auto_apply_percent, 25.0);
796 assert!(check_settings(&s).is_ok());
797 assert!(check_settings(&CostSettings { notice_days: 120, ..s.clone() }).is_err());
798 assert!(check_settings(&CostSettings { alert_days: 0, ..s.clone() }).is_err());
799 assert!(check_settings(&CostSettings { auto_apply_percent: 150.0, ..s }).is_err());
800 }
801}