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

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