flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/billing/src/lib.rs

910 lines34,068 bytesCodeBlame
1//! The billing service: what agents cost, charged to the workspace they
2//! worked for.
3//!
4//! A workspace buys credit with a card. Before the runner starts an agent
5//! it asks here, and is refused if the workspace has none. When the
6//! agent's sandbox finishes it reports what the model cost, and that plus
7//! g1t's margin comes off the balance. Every change is a ledger entry, and
8//! a balance is always the sum of its ledger.
9//!
10//! Paid features (deployments) are bought separately, as monthly plans;
11//! see `features`. They are never free.
12//!
13//! Without a card processor configured the service says so and charges
14//! nothing, so that g1t still runs where billing has not been set up.
15//!
16//! Reached only through service bindings; see `g1t_contracts::billing` for
17//! the methods and their arguments.
18
19mod features;
20mod limits;
21mod stripe;
22
23use g1t_contracts::billing::*;
24use g1t_contracts::time::rfc3339;
25use g1t_contracts::{FailureCode, Outcome, Role, new_id};
26use g1t_kit::{args, now_ms, reply, rpc_method};
27use serde::Deserialize;
28use sha2::{Digest, Sha256};
29use worker::wasm_bindgen::JsValue;
30use worker::{Context, D1Database, Env, Request, Response, Result, event};
31
32use stripe::Stripe;
33
34const MIN_TOP_UP_CENTS: u32 = 500;
35const MAX_TOP_UP_CENTS: u32 = 50_000;
36const LEDGER_PAGE: u32 = 100;
37/// A run's reported cost is believed up to this much. A sandbox cannot
38/// spend more in the time it has, so anything above is a fault.
39const MAX_RUN_COST_USD: f64 = 100.0;
40
41/// What a run is charged: its cost plus the margin, rounded up to a whole
42/// millionth of a dollar.
43pub fn charge_micros(cost_usd: f64, margin_percent: u32) -> i64 {
44 let cost_micros = (cost_usd.clamp(0.0, MAX_RUN_COST_USD) * MICROS_PER_DOLLAR as f64).ceil();
45 (cost_micros * f64::from(100 + margin_percent) / 100.0).ceil() as i64
46}
47
48fn hash(token: &str) -> String {
49 hex::encode(Sha256::digest(token.as_bytes()))
50}
51
52fn optional(value: Option<&str>) -> JsValue {
53 value.map_or(JsValue::NULL, JsValue::from)
54}
55
56#[derive(Deserialize)]
57struct AccountRow {
58 balance_micros: i64,
59 customer_id: Option<String>,
60}
61
62#[derive(Deserialize)]
63struct LedgerRow {
64 id: String,
65 kind: EntryKind,
66 amount_micros: i64,
67 description: String,
68 repo: Option<String>,
69 number: Option<u32>,
70 task: Option<String>,
71 model: Option<String>,
72 created_by: Option<String>,
73 created_at: String,
74 billed_to: Option<String>,
75}
76
77impl From<LedgerRow> for LedgerEntry {
78 fn from(row: LedgerRow) -> Self {
79 LedgerEntry {
80 id: row.id,
81 kind: row.kind,
82 amount_micros: row.amount_micros,
83 description: row.description,
84 repo: row.repo,
85 number: row.number,
86 task: row.task,
87 model: row.model,
88 billed_to: row.billed_to.unwrap_or_else(|| "g1t".to_owned()),
89 created_by: row.created_by,
90 created_at: row.created_at,
91 }
92 }
93}
94
95#[derive(Deserialize)]
96struct RunRow {
97 workspace: String,
98 repo: String,
99 number: u32,
100 task: String,
101 model: String,
102 token_hash: String,
103 billed_to: Option<String>,
104}
105
106impl RunRow {
107 fn own_provider(&self) -> bool {
108 self.billed_to.as_deref() == Some("workspace")
109 }
110}
111
112#[derive(Deserialize)]
113struct CheckoutRow {
114 workspace: String,
115 created_by: String,
116}
117
118/// A row an `UPDATE … RETURNING` touched.
119#[derive(Deserialize)]
120struct Touched {
121 #[allow(dead_code)]
122 id: String,
123}
124
125struct Billing {
126 db: D1Database,
127 /// Absent when no card processor is configured.
128 stripe: Option<Stripe>,
129 margin_percent: u32,
130 /// Charged for a run on the workspace's own model provider.
131 orchestration_fee_micros: i64,
132 /// While g1t is being built out, nothing is charged (`FREE_WHILE_BUILDING`).
133 free: bool,
134 /// The free allowance on g1t's hosted models, when there is one.
135 trial: Option<TrialConfig>,
136 /// The Deployments plan's monthly price (`DEPLOYMENTS_MONTHLY_CENTS`).
137 deployments_monthly_cents: u32,
138 /// How far unpaid usage may go; see `limits`.
139 ceilings: limits::Ceilings,
140}
141
142/// `TRIAL_WORKSPACE_MICROS`, `TRIAL_TOTAL_MICROS` and `TRIAL_UNTIL`.
143struct TrialConfig {
144 per_workspace_micros: i64,
145 total_micros: i64,
146 /// RFC 3339, in UTC.
147 until: String,
148}
149
150#[derive(serde::Deserialize)]
151struct Sum {
152 micros: Option<i64>,
153}
154
155impl Billing {
156 fn status(&self) -> Status {
157 Status {
158 enabled: self.stripe.is_some(),
159 live: self.stripe.as_ref().is_some_and(Stripe::live),
160 free: self.free,
161 }
162 }
163
164 async fn row(&self, workspace: &str) -> Result<Option<AccountRow>> {
165 self.db
166 .prepare("SELECT balance_micros, customer_id FROM accounts WHERE workspace = ?")
167 .bind(&[workspace.into()])?
168 .first::<AccountRow>(None)
169 .await
170 }
171
172 async fn standing(&self, workspace: &str) -> Result<Account> {
173 Ok(Account {
174 workspace: workspace.to_owned(),
175 balance_micros: self
176 .row(workspace)
177 .await?
178 .map_or(0, |row| row.balance_micros),
179 status: self.status(),
180 margin_percent: self.margin_percent,
181 orchestration_fee_micros: self.orchestration_fee_micros,
182 })
183 }
184
185 /// Adds a ledger entry and moves the balance by the same amount, as
186 /// one write.
187 #[allow(clippy::too_many_arguments)]
188 async fn enter(
189 &self,
190 workspace: &str,
191 kind: EntryKind,
192 amount_micros: i64,
193 description: &str,
194 reference: &str,
195 run: Option<&RunRow>,
196 cost_micros: Option<i64>,
197 created_by: Option<&str>,
198 customer: Option<&str>,
199 ) -> Result<()> {
200 let now = now_ms();
201 let timestamp = rfc3339(now);
202 let kind = match kind {
203 EntryKind::TopUp => "top_up",
204 EntryKind::Usage => "usage",
205 };
206 self.db
207 .batch(vec![
208 self.db
209 .prepare(
210 "INSERT INTO ledger
211 (id, workspace, kind, amount_micros, description, repo, number, task,
212 model, cost_micros, reference, created_by, created_at, billed_to)
213 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
214 )
215 .bind(&[
216 new_id("led", now).into(),
217 workspace.into(),
218 kind.into(),
219 // D1 takes numbers as doubles, which hold every
220 // amount this service will see exactly.
221 (amount_micros as f64).into(),
222 description.into(),
223 optional(run.map(|run| run.repo.as_str())),
224 run.map_or(JsValue::NULL, |run| run.number.into()),
225 optional(run.map(|run| run.task.as_str())),
226 optional(run.map(|run| run.model.as_str())),
227 cost_micros.map_or(JsValue::NULL, |cost| (cost as f64).into()),
228 reference.into(),
229 optional(created_by),
230 timestamp.as_str().into(),
231 run.map_or("g1t", |run| if run.own_provider() { "workspace" } else { "g1t" }).into(),
232 ])?,
233 self.db
234 .prepare(
235 "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at)
236 VALUES (?1, ?2, ?3, ?4)
237 ON CONFLICT (workspace) DO UPDATE SET
238 balance_micros = balance_micros + ?2,
239 customer_id = COALESCE(?3, customer_id)",
240 )
241 .bind(&[
242 workspace.into(),
243 (amount_micros as f64).into(),
244 optional(customer),
245 timestamp.as_str().into(),
246 ])?,
247 ])
248 .await?;
249 Ok(())
250 }
251
252 async fn account(&self, a: AccountArgs) -> Result<Outcome<Account>> {
253 let workspace = a.workspace.to_lowercase();
254 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
255 return Ok(members_only());
256 }
257 Ok(Outcome::Ok(self.standing(&workspace).await?))
258 }
259
260 async fn ledger(&self, a: AccountArgs) -> Result<Outcome<Vec<LedgerEntry>>> {
261 let workspace = a.workspace.to_lowercase();
262 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
263 return Ok(members_only());
264 }
265 let rows = self
266 .db
267 .prepare("SELECT * FROM ledger WHERE workspace = ? ORDER BY id DESC LIMIT ?")
268 .bind(&[workspace.into(), LEDGER_PAGE.into()])?
269 .all()
270 .await?
271 .results::<LedgerRow>()?;
272 Ok(Outcome::Ok(
273 rows.into_iter().map(LedgerEntry::from).collect(),
274 ))
275 }
276
277 async fn usage(&self, a: UsageArgs) -> Result<Outcome<Usage>> {
278 let workspace = a.workspace.to_lowercase();
279 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
280 return Ok(members_only());
281 }
282 #[derive(serde::Deserialize)]
283 struct SliceRow {
284 key: Option<String>,
285 micros: Option<i64>,
286 runs: Option<u32>,
287 }
288 // While nothing is charged, what was used is what there is to show.
289 let measure = if self.free { "COALESCE(cost_micros, 0)" } else { "-amount_micros" };
290 let slices = |key: &str, limit: u32| {
291 format!(
292 "SELECT {key} AS key, SUM({measure}) AS micros, COUNT(*) AS runs FROM ledger
293 WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2
294 GROUP BY 1 ORDER BY micros DESC LIMIT {limit}"
295 )
296 };
297 let query = |sql: String| {
298 let db = &self.db;
299 let workspace = workspace.clone();
300 let since = a.since.clone();
301 async move {
302 let rows = db
303 .prepare(sql)
304 .bind(&[workspace.into(), since.into()])?
305 .all()
306 .await?
307 .results::<SliceRow>()?;
308 Ok::<Vec<UsageSlice>, worker::Error>(
309 rows.into_iter()
310 .map(|row| UsageSlice {
311 key: row.key.unwrap_or_else(|| "other".to_owned()),
312 micros: row.micros.unwrap_or_default(),
313 runs: row.runs.unwrap_or_default(),
314 })
315 .collect(),
316 )
317 }
318 };
319 #[derive(serde::Deserialize)]
320 struct Totals {
321 spent: Option<i64>,
322 cost: Option<i64>,
323 provider: Option<i64>,
324 runs: Option<u32>,
325 added: Option<i64>,
326 }
327 let totals = self
328 .db
329 .prepare(
330 "SELECT
331 -SUM(CASE WHEN kind = 'usage' THEN amount_micros END) AS spent,
332 SUM(CASE WHEN kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' THEN cost_micros END) AS cost,
333 SUM(CASE WHEN kind = 'usage' AND billed_to = 'workspace' THEN cost_micros END) AS provider,
334 SUM(CASE WHEN kind = 'usage' THEN 1 ELSE 0 END) AS runs,
335 SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS added
336 FROM ledger WHERE workspace = ?1 AND created_at >= ?2",
337 )
338 .bind(&[workspace.as_str().into(), a.since.as_str().into()])?
339 .first::<Totals>(None)
340 .await?;
341 let totals = totals.unwrap_or(Totals {
342 spent: None,
343 cost: None,
344 provider: None,
345 runs: None,
346 added: None,
347 });
348 Ok(Outcome::Ok(Usage {
349 spent_micros: totals.spent.unwrap_or_default(),
350 cost_micros: totals.cost.unwrap_or_default(),
351 provider_micros: totals.provider.unwrap_or_default(),
352 used_micros: totals.cost.unwrap_or_default() + totals.provider.unwrap_or_default(),
353 free: self.free,
354 runs: totals.runs.unwrap_or_default(),
355 added_micros: totals.added.unwrap_or_default(),
356 by_day: query(slices("substr(created_at, 1, 10) || '/' || COALESCE(task, 'other')", 400)).await?,
357 by_task: query(slices("task", 20)).await?,
358 by_repo: query(slices("repo", 20)).await?,
359 by_pull: query(slices("repo || '#' || number", 10)).await?,
360 by_model: query(slices("model", 10)).await?,
361 since: a.since,
362 }))
363 }
364
365 async fn checkout(&self, a: CheckoutArgs) -> Result<Outcome<Checkout>> {
366 let workspace = a.workspace.to_lowercase();
367 if a.actor.role_in(&workspace) != Some(Role::Owner) {
368 return Ok(Outcome::fail(
369 FailureCode::Forbidden,
370 "Only an owner can add credit to a workspace.",
371 ));
372 }
373 let Some(stripe) = &self.stripe else {
374 return Ok(Outcome::fail(
375 FailureCode::Conflict,
376 "Payments are not set up on this g1t yet.",
377 ));
378 };
379 if !(MIN_TOP_UP_CENTS..=MAX_TOP_UP_CENTS).contains(&a.amount_cents) {
380 return Ok(Outcome::fail(
381 FailureCode::Invalid,
382 format!(
383 "Add between ${} and ${} at a time.",
384 MIN_TOP_UP_CENTS / 100,
385 MAX_TOP_UP_CENTS / 100
386 ),
387 ));
388 }
389 let customer = self.row(&workspace).await?.and_then(|row| row.customer_id);
390 let session = match stripe
391 .start_checkout(&workspace, a.amount_cents, customer.as_deref(), &a.return_url)
392 .await
393 {
394 Ok(session) => session,
395 // A customer saved under another Stripe account: start afresh.
396 Err(error) if customer.is_some() && stripe::is_missing(&error) => {
397 self.forget_customer(&workspace).await?;
398 stripe.start_checkout(&workspace, a.amount_cents, None, &a.return_url).await?
399 }
400 Err(error) => return Err(error),
401 };
402 let Some(url) = session.url else {
403 return Err(worker::Error::RustError(
404 "the card processor returned no payment page".into(),
405 ));
406 };
407 self.db
408 .prepare(
409 "INSERT INTO checkouts (id, workspace, amount_cents, created_by, created_at)
410 VALUES (?, ?, ?, ?, ?)",
411 )
412 .bind(&[
413 session.id.into(),
414 workspace.into(),
415 a.amount_cents.into(),
416 a.actor.username.into(),
417 rfc3339(now_ms()).into(),
418 ])?
419 .run()
420 .await?;
421 Ok(Outcome::Ok(Checkout { url }))
422 }
423
424 /// Credits a payment if the processor says it was made and it has not
425 /// been credited before. The amount credited is what the processor
426 /// says was paid, not what anyone here remembers asking for.
427 async fn confirm(&self, a: ConfirmArgs) -> Result<Outcome<Account>> {
428 let workspace = a.workspace.to_lowercase();
429 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
430 return Ok(members_only());
431 }
432 let (Some(stripe), Some(checkout)) = (
433 &self.stripe,
434 self.db
435 .prepare(
436 "SELECT workspace, created_by FROM checkouts
437 WHERE id = ? AND workspace = ? AND status = 'open'",
438 )
439 .bind(&[a.session.as_str().into(), workspace.as_str().into()])?
440 .first::<CheckoutRow>(None)
441 .await?,
442 ) else {
443 // Unknown, someone else's, or already credited: nothing to do.
444 return Ok(Outcome::Ok(self.standing(&workspace).await?));
445 };
446 let session = stripe.session(&a.session).await?;
447 let paid = session
448 .amount_total
449 .filter(|_| session.payment_status == "paid");
450 if let Some(cents) = paid {
451 // Only whoever flips it from open to paid enters the credit.
452 let claimed = self
453 .db
454 .prepare(
455 "UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open'
456 RETURNING id",
457 )
458 .bind(&[a.session.as_str().into()])?
459 .first::<Touched>(None)
460 .await?;
461 if claimed.is_some() {
462 self.enter(
463 &checkout.workspace,
464 EntryKind::TopUp,
465 i64::from(cents) * MICROS_PER_DOLLAR / 100,
466 "Credit added by card",
467 &session.id,
468 None,
469 None,
470 Some(&checkout.created_by),
471 session.customer.as_deref(),
472 )
473 .await?;
474 }
475 }
476 Ok(Outcome::Ok(self.standing(&workspace).await?))
477 }
478
479 /// Drops a saved customer the card processor no longer knows.
480 pub(crate) async fn forget_customer(&self, workspace: &str) -> Result<()> {
481 self.db
482 .prepare("UPDATE accounts SET customer_id = NULL WHERE workspace = ?")
483 .bind(&[workspace.into()])?
484 .run()
485 .await?;
486 Ok(())
487 }
488
489 /// A refusal if the workspace has no credit to start an agent with.
490 async fn out_of_credit<T>(&self, workspace: &str) -> Result<Option<Outcome<T>>> {
491 // While g1t is being built out, no one needs credit.
492 if self.free {
493 return Ok(None);
494 }
495 let balance = self
496 .row(workspace)
497 .await?
498 .map_or(0, |row| row.balance_micros);
499 Ok((balance <= 0).then(|| {
500 Outcome::fail(
501 FailureCode::PaymentRequired,
502 format!(
503 "The {workspace} workspace has no agent credit. An owner can add some under Billing on the workspace's page."
504 ),
505 )
506 }))
507 }
508
509 /// A workspace's free allowance on g1t's hosted models: what its runs
510 /// there have cost against its share, and the pool everyone draws on.
511 async fn trial(&self, a: TrialArgs) -> Result<Trial> {
512 let workspace = a.workspace.to_lowercase();
513 let Some(config) = &self.trial else {
514 return Ok(Trial {
515 open: false,
516 used_micros: 0,
517 limit_micros: 0,
518 ends_at: None,
519 reason: Some("off".to_owned()),
520 });
521 };
522 let used = self
523 .db
524 .prepare(
525 "SELECT SUM(cost_micros) AS micros FROM ledger
526 WHERE kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' AND workspace = ?",
527 )
528 .bind(&[workspace.as_str().into()])?
529 .first::<Sum>(None)
530 .await?
531 .and_then(|sum| sum.micros)
532 .unwrap_or_default();
533 // Everyone's, but for the workspaces open to hosted models anyway.
534 let exempt: Vec<String> = a.exempt.iter().map(|name| name.trim().to_lowercase()).collect();
535 let marks = vec!["?"; exempt.len().max(1)].join(", ");
536 let mut values: Vec<JsValue> = exempt.iter().map(|name| JsValue::from(name.as_str())).collect();
537 if values.is_empty() {
538 values.push(JsValue::from(""));
539 }
540 let pooled = self
541 .db
542 .prepare(format!(
543 "SELECT SUM(cost_micros) AS micros FROM ledger
544 WHERE kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' AND workspace NOT IN ({marks})"
545 ))
546 .bind(&values)?
547 .first::<Sum>(None)
548 .await?
549 .and_then(|sum| sum.micros)
550 .unwrap_or_default();
551 let reason = if rfc3339(now_ms()) >= config.until {
552 Some("ended")
553 } else if used >= config.per_workspace_micros {
554 Some("used")
555 } else if pooled >= config.total_micros {
556 Some("pool")
557 } else {
558 None
559 };
560 Ok(Trial {
561 open: reason.is_none(),
562 used_micros: used,
563 limit_micros: config.per_workspace_micros,
564 ends_at: Some(config.until.clone()),
565 reason: reason.map(str::to_owned),
566 })
567 }
568
569 async fn can_start(&self, a: CanStartArgs) -> Result<Outcome<bool>> {
570 if self.stripe.is_none() {
571 return Ok(Outcome::Ok(true));
572 }
573 if let Some(stopped) = self.stopped(&a.workspace).await? {
574 return Ok(stopped);
575 }
576 Ok(self
577 .out_of_credit(&a.workspace.to_lowercase())
578 .await?
579 .unwrap_or(Outcome::Ok(true)))
580 }
581
582 async fn start_run(&self, a: StartRunArgs) -> Result<Outcome<Option<RunTicket>>> {
583 if self.stripe.is_none() {
584 return Ok(Outcome::Ok(None));
585 }
586 let workspace = a.workspace.to_lowercase();
587 if let Some(stopped) = self.stopped(&workspace).await? {
588 return Ok(stopped);
589 }
590 if let Some(refused) = self.out_of_credit(&workspace).await? {
591 return Ok(refused);
592 }
593 let now = now_ms();
594 let run_id = new_id("run", now);
595 let mut bytes = [0u8; 32];
596 getrandom::getrandom(&mut bytes).expect("no source of randomness");
597 let token = hex::encode(bytes);
598 self.db
599 .prepare(
600 "INSERT INTO runs (id, workspace, repo, number, task, model, token_hash, created_at, billed_to)
601 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
602 )
603 .bind(&[
604 run_id.as_str().into(),
605 workspace.into(),
606 format!("{}/{}", a.repo.namespace, a.repo.name).into(),
607 a.number.into(),
608 a.task.into(),
609 a.model.into(),
610 hash(&token).into(),
611 rfc3339(now).into(),
612 if a.billed_to == "workspace" { "workspace" } else { "g1t" }.into(),
613 ])?
614 .run()
615 .await?;
616 Ok(Outcome::Ok(Some(RunTicket { run_id, token })))
617 }
618
619 async fn finish_run(&self, a: FinishRunArgs) -> Result<Outcome<bool>> {
620 let run = self
621 .db
622 .prepare(
623 "SELECT workspace, repo, number, task, model, token_hash, billed_to FROM runs
624 WHERE id = ? AND finished_at IS NULL",
625 )
626 .bind(&[a.run_id.as_str().into()])?
627 .first::<RunRow>(None)
628 .await?;
629 let Some(run) = run.filter(|run| run.token_hash == hash(&a.token)) else {
630 return Ok(Outcome::fail(FailureCode::NotFound, "Run not found."));
631 };
632 if !a.cost_usd.is_finite() || a.cost_usd < 0.0 {
633 return Ok(Outcome::fail(FailureCode::Invalid, "That is not a cost."));
634 }
635 // Only whoever closes the run charges for it.
636 let claimed = self
637 .db
638 .prepare(
639 "UPDATE runs SET finished_at = ? WHERE id = ? AND finished_at IS NULL RETURNING id",
640 )
641 .bind(&[rfc3339(now_ms()).into(), a.run_id.as_str().into()])?
642 .first::<Touched>(None)
643 .await?;
644 if claimed.is_none() {
645 return Ok(Outcome::Ok(false));
646 }
647 // On the workspace's own provider, the model was paid for there:
648 // g1t charges its fee, and keeps the provider's cost to show.
649 let charge = if self.free {
650 // Recorded, with what it cost, but not charged.
651 0
652 } else if run.own_provider() {
653 self.orchestration_fee_micros
654 } else {
655 charge_micros(a.cost_usd, self.margin_percent)
656 };
657 let mut description = match run.task.as_str() {
658 "plan" => format!("Planning for {}", run.repo),
659 "review" => format!("Review of {}#{}", run.repo, run.number),
660 "update" => format!("Catching up {}#{}", run.repo, run.number),
661 _ => format!("Work on {}#{}", run.repo, run.number),
662 };
663 if run.own_provider() {
664 description.push_str(", on your own model provider");
665 }
666 if self.free {
667 description.push_str(" (free while g1t is being built out)");
668 }
669 self.enter(
670 &run.workspace,
671 EntryKind::Usage,
672 -charge,
673 &description,
674 &a.run_id,
675 Some(&run),
676 Some(charge_micros(a.cost_usd, 0)),
677 None,
678 None,
679 )
680 .await?;
681 Ok(Outcome::Ok(true))
682 }
683}
684
685impl Billing {
686 /// Records how long a sandbox ran: its cost always, and a charge for
687 /// the seconds past the month's free minutes.
688 async fn record_sandbox(&self, a: RecordSandboxArgs) -> Result<Outcome<bool>> {
689 if self.stripe.is_none() || a.seconds == 0 {
690 return Ok(Outcome::Ok(false));
691 }
692 let workspace = a.workspace.to_lowercase();
693 let seen = self
694 .db
695 .prepare("SELECT id FROM ledger WHERE reference = ?")
696 .bind(&[a.reference.as_str().into()])?
697 .first::<Touched>(None)
698 .await?;
699 if seen.is_some() {
700 return Ok(Outcome::Ok(false));
701 }
702 let now = now_ms();
703 let timestamp = rfc3339(now);
704 let month = &timestamp[..7];
705 #[derive(Deserialize)]
706 struct Used {
707 seconds: i64,
708 }
709 let seconds = i64::from(a.seconds);
710 let after = self
711 .db
712 .prepare(
713 "INSERT INTO sandbox_months (workspace, month, seconds) VALUES (?1, ?2, ?3)
714 ON CONFLICT (workspace, month) DO UPDATE SET seconds = seconds + ?3
715 RETURNING seconds",
716 )
717 .bind(&[workspace.as_str().into(), month.into(), (seconds as f64).into()])?
718 .first::<Used>(None)
719 .await?
720 .map_or(seconds, |used| used.seconds);
721 let billable = sandbox_billable(after - seconds, seconds);
722 let charge = if self.free { 0 } else { billable * sandbox_allowance::MICROS_PER_SECOND };
723 let mut description = format!("{}: {} of sandbox time", a.description, duration(seconds));
724 if billable < seconds {
725 description.push_str(if billable == 0 {
726 ", within the month's free minutes"
727 } else {
728 ", partly within the month's free minutes"
729 });
730 }
731 if self.free && billable > 0 {
732 description.push_str(" (free while g1t is being built out)");
733 }
734 self.db
735 .batch(vec![
736 self.db
737 .prepare(
738 "INSERT INTO ledger
739 (id, workspace, kind, amount_micros, description, repo, task,
740 cost_micros, reference, created_at, billed_to)
741 VALUES (?, ?, 'usage', ?, ?, ?, 'sandbox', ?, ?, ?, 'g1t')",
742 )
743 .bind(&[
744 new_id("led", now).into(),
745 workspace.as_str().into(),
746 (-(charge as f64)).into(),
747 description.as_str().into(),
748 optional(a.repo.as_deref()),
749 ((seconds * sandbox_allowance::COST_MICROS_PER_SECOND) as f64).into(),
750 a.reference.as_str().into(),
751 timestamp.as_str().into(),
752 ])?,
753 self.db
754 .prepare(
755 "INSERT INTO accounts (workspace, balance_micros, created_at)
756 VALUES (?1, ?2, ?3)
757 ON CONFLICT (workspace) DO UPDATE SET balance_micros = balance_micros + ?2",
758 )
759 .bind(&[
760 workspace.as_str().into(),
761 (-(charge as f64)).into(),
762 timestamp.as_str().into(),
763 ])?,
764 ])
765 .await?;
766 Ok(Outcome::Ok(true))
767 }
768}
769
770/// Of `seconds` used after `before` this month, how many are past the
771/// free minutes.
772fn sandbox_billable(before: i64, seconds: i64) -> i64 {
773 let free_left = (sandbox_allowance::FREE_SECONDS - before).max(0);
774 (seconds - free_left).max(0)
775}
776
777/// `1h 2m`, `3m 12s` or `40s`.
778fn duration(seconds: i64) -> String {
779 let (h, m, s) = (seconds / 3600, seconds % 3600 / 60, seconds % 60);
780 if h > 0 {
781 format!("{h}h {m}m")
782 } else if m > 0 {
783 format!("{m}m {s}s")
784 } else {
785 format!("{s}s")
786 }
787}
788
789fn members_only<T>() -> Outcome<T> {
790 Outcome::fail(
791 FailureCode::Forbidden,
792 "Only members can see a workspace's billing.",
793 )
794}
795
796#[event(fetch)]
797async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
798 let Some(method) = rpc_method(&request) else {
799 return Response::error("Not found", 404);
800 };
801 let body: serde_json::Value = request.json().await?;
802 let billing = Billing {
803 db: env.d1("DB")?,
804 stripe: env
805 .secret("STRIPE_SECRET_KEY")
806 .ok()
807 .map(|key| key.to_string())
808 .filter(|key| !key.is_empty())
809 .map(Stripe::new),
810 margin_percent: env
811 .var("MARGIN_PERCENT")
812 .ok()
813 .and_then(|percent| percent.to_string().parse().ok())
814 .unwrap_or(20),
815 orchestration_fee_micros: env
816 .var("ORCHESTRATION_FEE_MICROS")
817 .ok()
818 .and_then(|fee| fee.to_string().parse().ok())
819 .unwrap_or(100_000),
820 free: env.var("FREE_WHILE_BUILDING").is_ok_and(|v| v.to_string() == "true"),
821 ceilings: limits::Ceilings::from_env(&env),
822 deployments_monthly_cents: env
823 .var("DEPLOYMENTS_MONTHLY_CENTS")
824 .ok()
825 .and_then(|cents| cents.to_string().parse().ok())
826 .unwrap_or(500),
827 trial: {
828 let number = |name: &str| env.var(name).ok().and_then(|v| v.to_string().parse::<i64>().ok());
829 match (
830 number("TRIAL_WORKSPACE_MICROS"),
831 number("TRIAL_TOTAL_MICROS"),
832 env.var("TRIAL_UNTIL").ok().map(|v| v.to_string()),
833 ) {
834 (Some(per_workspace_micros), Some(total_micros), Some(until))
835 if per_workspace_micros > 0 && !until.is_empty() =>
836 {
837 Some(TrialConfig {
838 per_workspace_micros,
839 total_micros,
840 until,
841 })
842 }
843 _ => None,
844 }
845 },
846 };
847 match method.as_str() {
848 "status" => reply(&billing.status()),
849 "account" => reply(&billing.account(args(body)?).await?),
850 "ledger" => reply(&billing.ledger(args(body)?).await?),
851 "usage" => reply(&billing.usage(args(body)?).await?),
852 "checkout" => reply(&billing.checkout(args(body)?).await?),
853 "confirm" => reply(&billing.confirm(args(body)?).await?),
854 "can_start" => reply(&billing.can_start(args(body)?).await?),
855 "trial" => reply(&billing.trial(args(body)?).await?),
856 "start_run" => reply(&billing.start_run(args(body)?).await?),
857 "finish_run" => reply(&billing.finish_run(args(body)?).await?),
858 "features" => reply(&billing.features(args(body)?).await?),
859 "subscribe" => reply(&billing.subscribe(args(body)?).await?),
860 "confirm_subscription" => reply(&billing.confirm_subscription(args(body)?).await?),
861 "cancel_subscription" => reply(&billing.cancel_subscription(args(body)?).await?),
862 "has_feature" => reply(&billing.has_feature(args(body)?).await?),
863 "charge_feature" => reply(&billing.charge_feature(args(body)?).await?),
864 "record_sandbox" => reply(&billing.record_sandbox(args(body)?).await?),
865 "limit" => reply(&billing.limit(args(body)?).await?),
866 "check_limit" => reply(&billing.check_limit(args(body)?).await?),
867 "set_spend_limit" => reply(&billing.set_spend_limit(args(body)?).await?),
868 _ => Response::error("Unknown method", 404),
869 }
870}
871
872#[cfg(test)]
873mod tests {
874 use super::*;
875
876 #[test]
877 fn a_run_is_charged_its_cost_plus_the_margin() {
878 // $0.05 at 20% is six cents.
879 assert_eq!(charge_micros(0.05, 20), 60_000);
880 assert_eq!(charge_micros(1.0, 20), 1_200_000);
881 assert_eq!(charge_micros(0.05, 0), 50_000);
882 }
883
884 #[test]
885 fn fractions_of_a_millionth_round_up_and_nothing_costs_less_than_nothing() {
886 assert_eq!(charge_micros(0.000_000_4, 20), 2);
887 assert_eq!(charge_micros(0.0, 20), 0);
888 assert_eq!(charge_micros(-3.0, 20), 0);
889 }
890
891 #[test]
892 fn sandbox_seconds_are_charged_only_past_the_free_minutes() {
893 let free = sandbox_allowance::FREE_SECONDS;
894 assert_eq!(sandbox_billable(0, 600), 0);
895 assert_eq!(sandbox_billable(free - 100, 600), 500);
896 assert_eq!(sandbox_billable(free + 5, 600), 600);
897 }
898
899 #[test]
900 fn durations_read_plainly() {
901 assert_eq!(duration(40), "40s");
902 assert_eq!(duration(192), "3m 12s");
903 assert_eq!(duration(3720), "1h 2m");
904 }
905
906 #[test]
907 fn an_absurd_cost_is_capped() {
908 assert_eq!(charge_micros(1e9, 20), 120 * MICROS_PER_DOLLAR);
909 }
910}