Skip to content

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.sh
1,075 linesCodeBlameRaw

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.

Merge branch 'worktree-agent-a985c01f43538af01'1//! Stripe telling billing what happened, and enterprise invoices.
2//!
3//! Most of billing asks Stripe when it needs to know: a payment page is
4//! confirmed when the person comes back, a plan is checked when its period
5//! ends. That misses whatever happens while no one is looking: a page paid
6//! for and closed, a renewal that failed, a refund, a dispute, an invoice
7//! paid by bank transfer a week later. Stripe sends each as an event to
8//! `https://api.g1t.sh/stripe/webhook`; the API passes the raw body and its
9//! signature here, untouched.
10//!
11//! - The endpoint is registered by billing itself, from sudo, once per
12//! mode, and its signing secret is kept in billing's database. It is never
13//! shown, and nothing can be posted here without it.
14//! - Each event is handled once, by id, and recorded with what was done.
15//! - Every handler is safe alongside the paths that ask Stripe directly:
16//! both claim the same rows.
17//!
18//! Enterprises are invoiced: one Stripe invoice per month (or sooner, from
19//! sudo), with a line per workspace for what it owes, sent to the
20//! enterprise's billing email and paid on Stripe's hosted invoice page.
21//! When it is paid, each workspace is credited its line; when it goes
22//! overdue, their work stops until it is paid.
23
24use g1t_contracts::billing::{
Merge Stripe Tax, the card fee on card payments, and one free workspace per person25 AdminEnterpriseAddressArgs, AdminEnterpriseBillingArgs, AdminInvoiceEnterpriseArgs, AdminStripeArgs, BillingAccount, EnterpriseInvoice,
Merge branch 'worktree-agent-a985c01f43538af01'26 EntryKind, InvoiceLine, StripeEventSummary, StripeStatus, StripeWebhook, StripeWebhookArgs,
27};
28use g1t_contracts::time::rfc3339;
29use g1t_contracts::{FailureCode, Outcome};
30use g1t_kit::now_ms;
31use hmac::{Hmac, Mac};
32use serde::Deserialize;
33use serde_json::Value;
34use sha2::Sha256;
35use worker::Result;
36use worker::wasm_bindgen::JsValue;
37
38use crate::Billing;
39
40/// Where Stripe sends events.
41pub(crate) const WEBHOOK_URL: &str = "https://api.g1t.sh/stripe/webhook";
42
43/// The events billing acts on.
44pub(crate) const EVENTS: &[&str] = &[
45 "checkout.session.completed",
46 // A prepayment by bank transfer: the page completes when the transfer
47 // is set up, and this comes when the money arrives.
48 "checkout.session.async_payment_succeeded",
49 "customer.subscription.updated",
50 "customer.subscription.deleted",
51 "invoice.paid",
52 "invoice.payment_failed",
53 "invoice.overdue",
54 "invoice.voided",
55 "charge.refunded",
56 "charge.dispute.created",
57 "charge.dispute.closed",
58 // The saved card, kept on the account row (stripe_sync.rs).
59 "customer.updated",
60 "payment_method.attached",
61 "payment_method.detached",
62 "payment_method.updated",
63 "payment_method.automatically_updated",
64 "setup_intent.succeeded",
65];
66
Usage, Billing settings and prepaid AI credit; fixes from the UX audit67/// The subscription an invoice is for: `subscription` at the API version
68/// billing asks in, `parent.subscription_details.subscription` in events
69/// sent at a newer one (a destination keeps the version it was made at).
70pub(crate) fn invoice_subscription(invoice: &Value) -> Option<&str> {
71 invoice["subscription"].as_str().or_else(|| invoice["parent"]["subscription_details"]["subscription"].as_str())
72}
73
Merge branch 'worktree-agent-a985c01f43538af01'74/// Events billing handles that `has` does not include, in billing's order.
75pub(crate) fn missing_events(has: &[String]) -> Vec<String> {
76 // `*` is every event.
77 if has.iter().any(|event| event == "*") {
78 return Vec::new();
79 }
80 EVENTS.iter().filter(|event| !has.iter().any(|h| h == *event)).map(|event| (*event).to_owned()).collect()
81}
82
83/// How old a signed event may be, so a captured one cannot be replayed.
84const TOLERANCE_SECONDS: i64 = 5 * 60;
85
86/// Whether `header` (`t=…,v1=…`) signs `payload` with `secret`, within the
87/// tolerance of `now_seconds`.
88pub(crate) fn verify(payload: &str, header: &str, secret: &str, now_seconds: i64) -> bool {
89 let mut timestamp = None;
90 let mut signatures = vec![];
91 for part in header.split(',') {
92 match part.trim().split_once('=') {
93 Some(("t", value)) => timestamp = value.parse::<i64>().ok(),
94 Some(("v1", value)) => signatures.push(value.to_owned()),
95 _ => {}
96 }
97 }
98 let Some(timestamp) = timestamp else { return false };
99 if (now_seconds - timestamp).abs() > TOLERANCE_SECONDS {
100 return false;
101 }
102 let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else { return false };
103 mac.update(format!("{timestamp}.{payload}").as_bytes());
104 let expected = mac.finalize().into_bytes();
105 signatures.iter().any(|signature| {
106 hex::decode(signature).is_ok_and(|given| {
107 // Constant time: compare every byte whatever the first difference.
108 given.len() == expected.len() && given.iter().zip(expected.iter()).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0
109 })
110 })
111}
112
113/// The destination at billing's address, as Stripe lists it.
114#[derive(Deserialize)]
115pub(crate) struct Destination {
116 pub(crate) id: String,
117 url: String,
118 /// `enabled` or `disabled`.
119 pub(crate) status: String,
120 pub(crate) enabled_events: Vec<String>,
121 created: i64,
122}
123
124#[derive(Deserialize)]
125struct EventRow {
126 id: String,
127 r#type: String,
128 outcome: String,
129 received_at: String,
130}
131
132#[derive(Deserialize)]
133struct InvoiceRow {
134 invoice_id: String,
135 period: String,
136 amount_micros: i64,
137 status: String,
138 hosted_url: Option<String>,
139 created_at: String,
140}
141
142#[derive(Deserialize)]
143struct LineRow {
144 workspace: String,
145 amount_micros: i64,
146}
147
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging148/// The idempotency key of an enterprise's invoice: the same enterprise,
149/// period and amounts are the same invoice however often it is attempted.
150pub(crate) fn enterprise_invoice_key(id: &str, period: &str, lines: &[(&str, i64)]) -> String {
151 // FNV-1a over every line, so the key stays short however many there are.
152 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
153 for (workspace, cents) in lines {
154 for byte in format!("{workspace}={cents};").bytes() {
155 hash = (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3);
156 }
157 }
158 format!("ent-invoice/{id}/{period}/{hash:016x}")
159}
160
Merge branch 'worktree-agent-a985c01f43538af01'161impl Billing {
162 pub(crate) fn mode(&self) -> &'static str {
163 match &self.stripe {
164 None => "off",
165 Some(stripe) if stripe.live() => "live",
166 Some(_) => "test",
167 }
168 }
169
170 /// The destination at billing's address in Stripe, for this key's
171 /// mode: an enabled one first. None when there is none.
172 pub(crate) async fn destination(&self) -> Result<Option<Destination>> {
173 let Some(stripe) = &self.stripe else { return Ok(None) };
174 #[derive(Deserialize)]
175 struct List {
176 data: Vec<Destination>,
177 }
178 let mut ours: Vec<Destination> =
179 stripe.get::<List>("/webhook_endpoints?limit=100").await?.data.into_iter().filter(|d| d.url == WEBHOOK_URL).collect();
180 ours.sort_by_key(|d| d.status != "enabled");
181 Ok(ours.into_iter().next())
182 }
183
184 // --- Staff ------------------------------------------------------------
185
186 pub(crate) async fn admin_stripe(&self, a: AdminStripeArgs) -> Result<StripeStatus> {
187 let mut error = None;
188 if a.fix
189 && let Err(e) = self.keep_endpoint(a.by.as_deref().unwrap_or("sudo")).await
190 {
191 error = Some(e.to_string());
192 }
193 let (webhook, missing_events) = match self.destination().await {
194 Ok(Some(d)) => {
195 let missing = missing_events(&d.enabled_events);
196 let webhook = StripeWebhook {
197 url: d.url,
198 endpoint_id: d.id,
199 status: d.status,
200 events: d.enabled_events,
201 created_at: rfc3339(d.created.max(0) as u64 * 1000),
202 };
203 (Some(webhook), missing)
204 }
205 Ok(None) => (None, Vec::new()),
206 Err(e) => {
207 error = error.or(Some(format!("Stripe could not be read: {e}")));
208 (None, Vec::new())
209 }
210 };
211 let recent_events = self
212 .db
213 .prepare("SELECT * FROM stripe_events ORDER BY received_at DESC LIMIT 25")
214 .all()
215 .await?
216 .results::<EventRow>()?
217 .into_iter()
218 .map(|row| StripeEventSummary { id: row.id, kind: row.r#type, outcome: row.outcome, received_at: row.received_at })
219 .collect();
220 Ok(StripeStatus {
221 mode: self.mode().to_owned(),
222 secret_set: self.webhook_secret.is_some(),
223 webhook,
224 missing_events,
225 recent_events,
226 error,
227 })
228 }
229
230 // --- Events -----------------------------------------------------------
231
232 pub(crate) async fn stripe_webhook(&self, a: StripeWebhookArgs) -> Result<Outcome<bool>> {
233 let Some(secret) = &self.webhook_secret else {
234 return Ok(Outcome::fail(
235 FailureCode::Conflict,
236 "STRIPE_WEBHOOK_SECRET is not set, so billing cannot check that events come from Stripe.",
237 ));
238 };
239 let now_seconds = (now_ms() / 1000) as i64;
240 if !verify(&a.payload, &a.signature, secret, now_seconds) {
241 return Ok(Outcome::fail(FailureCode::Forbidden, "The signature does not match."));
242 }
243 let event: Value = serde_json::from_str(&a.payload).map_err(|e| worker::Error::RustError(e.to_string()))?;
244 if event["id"].as_str().unwrap_or_default().is_empty() {
245 return Ok(Outcome::fail(FailureCode::Invalid, "Not an event."));
246 }
247 Ok(Outcome::Ok(self.process_event(&event).await?))
248 }
249
250 /// Handles a Stripe event once, however often it arrives: by webhook,
251 /// or again from the event list (stripe_sync.rs). False when it was
252 /// seen before.
253 pub(crate) async fn process_event(&self, event: &Value) -> Result<bool> {
254 let id = event["id"].as_str().unwrap_or_default().to_owned();
255 let kind = event["type"].as_str().unwrap_or_default().to_owned();
256 // Once each: the first to record it handles it.
257 let claimed = self
258 .db
259 .prepare("INSERT OR IGNORE INTO stripe_events (id, type, outcome, received_at) VALUES (?, ?, 'handling', ?) RETURNING id")
260 .bind(&[id.as_str().into(), kind.as_str().into(), rfc3339(now_ms()).into()])?
261 .first::<Value>(None)
262 .await?;
263 if claimed.is_none() {
264 return Ok(false);
265 }
266 let outcome = match self.handle(&kind, event).await {
267 Ok(outcome) => outcome,
268 Err(error) => {
269 // Let Stripe send it again: forget it was seen.
270 self.db.prepare("DELETE FROM stripe_events WHERE id = ?").bind(&[id.as_str().into()])?.run().await?;
271 return Err(error);
272 }
273 };
274 self.db
275 .prepare("UPDATE stripe_events SET outcome = ? WHERE id = ?")
276 .bind(&[outcome.as_str().into(), id.as_str().into()])?
277 .run()
278 .await?;
279 Ok(true)
280 }
281
282 async fn handle(&self, kind: &str, event: &Value) -> Result<String> {
283 let object = &event["data"]["object"];
284 let text = |key: &str| object[key].as_str().unwrap_or_default().to_owned();
285 // The customer whose saved card may have changed. A detached card
286 // has no customer any more; the event says whose it was.
287 let card_owner = match kind {
288 "customer.updated" => object["id"].as_str(),
289 "payment_method.detached" => event["data"]["previous_attributes"]["customer"].as_str(),
290 _ => object["customer"].as_str(),
291 };
292 Ok(match kind {
293 "checkout.session.completed" | "checkout.session.async_payment_succeeded" => self.settle_checkout(&text("id")).await?,
294 "customer.subscription.updated" | "customer.subscription.deleted" => {
295 self.settle_subscription(&text("id")).await?
296 }
297 "invoice.paid" => {
Usage, Billing settings and prepaid AI credit; fixes from the UX audit298 if let Some(subscription) = invoice_subscription(object) {
Merge Stripe Tax, the card fee on card payments, and one free workspace per person299 self.plan_paid(subscription, object).await?;
Merge branch 'worktree-agent-a985c01f43538af01'300 self.settle_subscription(subscription).await?
301 } else if let Some(done) = self.workspace_invoice_paid(&text("id")).await? {
302 done
303 } else {
Merge Stripe Tax, the card fee on card payments, and one free workspace per person304 self.enterprise_invoice_paid(object).await?
Merge branch 'worktree-agent-a985c01f43538af01'305 }
306 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit307 "invoice.payment_failed" => match (invoice_subscription(object), object["metadata"]["g1t_workspace"].as_str()) {
Merge branch 'worktree-agent-a985c01f43538af01'308 (Some(subscription), _) => self.settle_subscription(subscription).await?,
309 (None, Some(tagged)) => {
310 // The invoice's own row names the workspace as it is
311 // now; the metadata keeps the slug it was sent under.
312 let workspace = self.workspace_of_invoice(&text("id")).await?.unwrap_or_else(|| tagged.to_owned());
313 let workspace = workspace.as_str();
314 self.mark_declined(workspace, "the card was declined for an invoice").await?;
315 format!("{workspace}: invoice payment failed; work stopped")
316 }
317 _ => "ignored: not g1t's".to_owned(),
318 },
319 "invoice.overdue" => self.enterprise_invoice_status(&text("id"), "overdue").await?,
320 "invoice.voided" => self.enterprise_invoice_status(&text("id"), "void").await?,
321 "charge.refunded" => self.refunded(object).await?,
322 "charge.dispute.created" => self.disputed(object, true).await?,
323 "charge.dispute.closed" => self.disputed(object, object["status"].as_str() == Some("lost")).await?,
324 "customer.updated"
325 | "payment_method.attached"
326 | "payment_method.detached"
327 | "payment_method.updated"
328 | "payment_method.automatically_updated"
329 | "setup_intent.succeeded" => match card_owner {
330 Some(customer) => self.sync_card_of(customer).await?,
331 None => "ignored: no customer".to_owned(),
332 },
333 _ => "ignored".to_owned(),
334 })
335 }
336
337 /// A payment page done, whether or not the person came back to g1t.
338 async fn settle_checkout(&self, session_id: &str) -> Result<String> {
339 #[derive(Deserialize)]
340 struct Open {
341 workspace: String,
342 created_by: String,
343 feature: Option<String>,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person344 #[serde(default)]
345 fee_cents: Option<u32>,
Merge branch 'worktree-agent-a985c01f43538af01'346 }
347 let Some(open) = self
348 .db
Merge Stripe Tax, the card fee on card payments, and one free workspace per person349 .prepare("SELECT workspace, created_by, feature, fee_cents FROM checkouts WHERE id = ? AND status = 'open'")
Merge branch 'worktree-agent-a985c01f43538af01'350 .bind(&[session_id.into()])?
351 .first::<Open>(None)
352 .await?
353 else {
354 return Ok("ignored: already settled or not g1t's".to_owned());
355 };
356 // A card check: saved and verified, never charged.
357 if open.feature.as_deref() == Some(crate::cards::CARD_CHECK) {
358 return Ok(match self.settle_card_check(session_id).await? {
359 Ok(done) => done,
360 Err(why) => format!("card check not passed: {why}"),
361 });
362 }
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.363 // AI credit, from before g1t stopped selling it: a page left open
364 // then is never credited as anything else.
365 if open.feature.as_deref() == Some("ai_credit") {
366 return Ok("ignored: AI credit is no longer sold".to_owned());
Usage, Billing settings and prepaid AI credit; fixes from the UX audit367 }
Merge branch 'worktree-agent-a985c01f43538af01'368 let Some(stripe) = &self.stripe else { return Ok("ignored: payments off".to_owned()) };
369 let session = stripe.session(session_id).await?;
370 if session.payment_status != "paid" && open.feature.is_none() {
371 return Ok("ignored: not paid".to_owned());
372 }
373 let claimed = self
374 .db
375 .prepare("UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open' RETURNING id")
376 .bind(&[session_id.into()])?
377 .first::<Value>(None)
378 .await?;
379 if claimed.is_none() {
380 return Ok("ignored: settled meanwhile".to_owned());
381 }
382 match open.feature.as_deref() {
383 None => {
Merge Stripe Tax, the card fee on card payments, and one free workspace per person384 let cents = self.credit_prepayment(&open.workspace, &session, open.fee_cents.unwrap_or(0), &open.created_by).await?;
Merge branch 'worktree-agent-a985c01f43538af01'385 Ok(format!("credited {} to {}", crate::features::dollars(cents * 10_000), open.workspace))
386 }
387 Some(feature) => {
388 let Some(feature) = g1t_contracts::billing::Feature::parse(feature) else {
389 return Ok("ignored: unknown feature".to_owned());
390 };
391 if let Some(subscription_id) = &session.subscription {
392 let subscription = stripe.subscription(subscription_id).await?;
393 self.record(&open.workspace, feature, &subscription, &open.created_by).await?;
394 }
395 self.db
396 .prepare(
397 "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at) VALUES (?1, 0, ?2, ?3)
398 ON CONFLICT (workspace) DO UPDATE SET customer_id = COALESCE(customer_id, ?2)",
399 )
400 .bind(&[open.workspace.as_str().into(), crate::optional(session.customer.as_deref()), rfc3339(now_ms()).into()])?
401 .run()
402 .await?;
403 Ok(format!("{} plan started for {}", feature.title(), open.workspace))
404 }
405 }
406 }
407
408 /// The plan's monthly price, paid: revenue that never goes through the
409 /// ledger, recorded once per invoice for sudo's figures and for trust.
Merge Stripe Tax, the card fee on card payments, and one free workspace per person410 /// Only the plan's own price is revenue: the invoice's tax and card fee
411 /// are kept apart (`tax_and_fees`).
412 async fn plan_paid(&self, subscription_id: &str, invoice: &Value) -> Result<()> {
413 let invoice_id = invoice["id"].as_str().unwrap_or_default();
414 let split = crate::stripe::invoice_split(invoice, invoice["amount_paid"].as_i64().unwrap_or(0));
415 let amount_cents = split.net_cents;
Merge branch 'worktree-agent-a985c01f43538af01'416 if amount_cents <= 0 || invoice_id.is_empty() {
417 return Ok(());
418 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person419 #[derive(Deserialize)]
420 struct Owner {
421 workspace: String,
422 }
423 if let Some(owner) = self
424 .db
425 .prepare("SELECT workspace FROM subscriptions WHERE subscription_id = ?")
426 .bind(&[subscription_id.into()])?
427 .first::<Owner>(None)
428 .await?
429 {
430 let extras = crate::tax::Extras { tax_cents: split.tax_cents, fee_cents: split.fee_cents };
431 self.record_extras(&owner.workspace, invoice_id, invoice["payment_intent"].as_str(), extras, None).await?;
432 }
Merge branch 'worktree-agent-a985c01f43538af01'433 self.db
434 .prepare(
435 "INSERT INTO plan_payments (invoice_id, workspace, amount_micros, paid_at)
436 SELECT ?1, workspace, ?2, ?3 FROM subscriptions WHERE subscription_id = ?4
437 ON CONFLICT (invoice_id) DO NOTHING",
438 )
439 .bind(&[
440 invoice_id.into(),
441 ((amount_cents * 10_000) as f64).into(),
442 rfc3339(now_ms()).into(),
443 subscription_id.into(),
444 ])?
445 .run()
446 .await?;
447 Ok(())
448 }
449
450 /// A plan that changed at Stripe: renewed, failed, canceled.
451 pub(crate) async fn settle_subscription(&self, subscription_id: &str) -> Result<String> {
452 #[derive(Deserialize)]
453 struct Plan {
454 workspace: String,
455 feature: String,
456 started_by: String,
457 }
458 let Some(plan) = self
459 .db
460 .prepare("SELECT workspace, feature, started_by FROM subscriptions WHERE subscription_id = ?")
461 .bind(&[subscription_id.into()])?
462 .first::<Plan>(None)
463 .await?
464 else {
465 return Ok("ignored: not a g1t plan".to_owned());
466 };
467 let (Some(stripe), Some(feature)) = (&self.stripe, g1t_contracts::billing::Feature::parse(&plan.feature)) else {
468 return Ok("ignored".to_owned());
469 };
470 let subscription = stripe.subscription(subscription_id).await?;
471 self.record(&plan.workspace, feature, &subscription, &plan.started_by).await?;
472 Ok(format!("{} plan for {} is {}", feature.title(), plan.workspace, subscription.status))
473 }
474
475 /// The workspace a Stripe customer belongs to.
476 pub(crate) async fn workspace_of_customer(&self, customer: &str) -> Result<Option<String>> {
477 #[derive(Deserialize)]
478 struct Row {
479 workspace: String,
480 }
481 Ok(self
482 .db
483 .prepare("SELECT workspace FROM accounts WHERE customer_id = ?")
484 .bind(&[customer.into()])?
485 .first::<Row>(None)
486 .await?
487 .map(|row| row.workspace))
488 }
489
490 /// The workspace a workspace invoice was sent to, under its slug now.
491 async fn workspace_of_invoice(&self, invoice_id: &str) -> Result<Option<String>> {
492 #[derive(Deserialize)]
493 struct Row {
494 workspace: String,
495 }
496 Ok(self
497 .db
498 .prepare("SELECT workspace FROM workspace_invoices WHERE invoice_id = ?")
499 .bind(&[invoice_id.into()])?
500 .first::<Row>(None)
501 .await?
502 .map(|row| row.workspace))
503 }
504
505 /// Money given back: what was paid is less, by the refund.
506 async fn refunded(&self, charge: &Value) -> Result<String> {
507 let Some(customer) = charge["customer"].as_str() else { return Ok("ignored: no customer".to_owned()) };
508 let Some(workspace) = self.workspace_of_customer(customer).await? else {
509 return Ok("ignored: not a workspace's customer".to_owned());
510 };
511 let refunded = charge["amount_refunded"].as_i64().unwrap_or(0);
512 if refunded <= 0 {
513 return Ok("ignored: nothing refunded".to_owned());
514 }
515 // Each refund total once, so partial refunds add up correctly.
516 let charge_id = charge["id"].as_str().unwrap_or_default();
517 #[derive(Deserialize)]
518 struct Sum {
519 micros: Option<i64>,
520 }
521 let already = self
522 .db
523 .prepare("SELECT -SUM(amount_micros) AS micros FROM ledger WHERE reference LIKE ?")
524 .bind(&[format!("refund/{charge_id}/%").into()])?
525 .first::<Sum>(None)
526 .await?
527 .and_then(|s| s.micros)
528 .unwrap_or(0);
Merge Stripe Tax, the card fee on card payments, and one free workspace per person529 // A refund gives back the payment's tax and card fee in proportion:
530 // only the rest comes off the balance, which never held them.
531 let (extras, transaction) = self.extras_of(charge["payment_intent"].as_str(), charge["invoice"].as_str()).await?;
532 let paid = charge["amount"].as_i64().unwrap_or(refunded);
533 let (balance_cents, tax_cents, fee_cents) = crate::tax::refund_split(refunded, paid, extras.tax_cents, extras.fee_cents);
534 let (given_tax, given_fee) = self.refunded_extras(charge_id).await?;
535 let new = balance_cents * 10_000 - already;
536 let new_extras = crate::tax::Extras { tax_cents: -(tax_cents - given_tax), fee_cents: -(fee_cents - given_fee) };
537 if new <= 0 && new_extras == crate::tax::Extras::default() {
Merge branch 'worktree-agent-a985c01f43538af01'538 return Ok("ignored: refund already recorded".to_owned());
539 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person540 let reference = format!("refund/{charge_id}/{refunded}");
541 if new > 0 {
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.542 self.enter(&workspace, EntryKind::TopUp, -new, "Refunded to the card", &reference, None, None, None).await?;
Merge Stripe Tax, the card fee on card payments, and one free workspace per person543 }
544 self.record_extras(&workspace, &reference, charge["payment_intent"].as_str(), new_extras, None).await?;
545 // An off-session charge's tax was recorded by g1t (auto-reload):
546 // reverse what this refund gave back. Checkout's and invoices' tax
547 // Stripe Tax keeps itself.
548 if let (Some(stripe), Some(transaction)) = (&self.stripe, transaction.as_deref()) {
549 let given_back = new.max(0) / 10_000 - new_extras.tax_cents - new_extras.fee_cents;
550 if given_back > 0
551 && let Err(error) = stripe.reverse_tax(transaction, &reference, given_back).await
552 {
553 worker::console_error!("{workspace}: the tax on refund {reference} was not reversed: {error}");
554 }
555 }
556 Ok(format!("refund of {} recorded for {workspace}", crate::features::dollars(new.max(0))))
557 }
558
559 /// What earlier refunds of a charge gave back of its tax and card fee,
560 /// in cents, as positive amounts.
561 async fn refunded_extras(&self, charge_id: &str) -> Result<(i64, i64)> {
562 #[derive(Deserialize)]
563 struct Row {
564 kind: String,
565 micros: Option<i64>,
566 }
567 let rows = self
568 .db
569 .prepare("SELECT kind, SUM(amount_micros) AS micros FROM tax_and_fees WHERE reference LIKE ? GROUP BY kind")
570 .bind(&[format!("refund/{charge_id}/%").into()])?
571 .all()
572 .await?
573 .results::<Row>()?;
574 let of = |kind: &str| rows.iter().filter(|r| r.kind == kind).map(|r| -r.micros.unwrap_or(0) / 10_000).sum::<i64>();
575 Ok((of("tax"), of("card_fee")))
Merge branch 'worktree-agent-a985c01f43538af01'576 }
577
578 /// A disputed payment stops the workspace's work until it is resolved;
579 /// one closed in the workspace's favour lets it go on.
580 async fn disputed(&self, dispute: &Value, stop: bool) -> Result<String> {
581 let Some(stripe) = &self.stripe else { return Ok("ignored".to_owned()) };
582 let Some(charge_id) = dispute["charge"].as_str() else { return Ok("ignored: no charge".to_owned()) };
583 let charge: Value = stripe.get(&format!("/charges/{charge_id}")).await?;
584 let Some(workspace) = (match charge["customer"].as_str() {
585 Some(customer) => self.workspace_of_customer(customer).await?,
586 None => None,
587 }) else {
588 return Ok("ignored: not a workspace's customer".to_owned());
589 };
590 let now = rfc3339(now_ms());
591 // The disputed payment never counts toward trust again.
592 for reference in [charge["payment_intent"].as_str(), charge["invoice"].as_str()].into_iter().flatten() {
593 self.db
594 .prepare("UPDATE ledger SET disputed = ? WHERE workspace = ? AND reference = ?")
595 .bind(&[(if stop { 1 } else { 0 }).into(), workspace.as_str().into(), reference.into()])?
596 .run()
597 .await?;
598 }
599 if stop {
600 self.db
601 .prepare(
602 "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2)
603 ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2",
604 )
605 .bind(&[workspace.as_str().into(), now.as_str().into(), "a payment was disputed with the card's bank".into()])?
606 .run()
607 .await?;
608 } else {
609 self.db
610 .prepare("UPDATE limits SET autopay_failed_at = NULL, autopay_error = NULL WHERE workspace = ?")
611 .bind(&[workspace.as_str().into()])?
612 .run()
613 .await?;
614 }
615 let account = self.account_of(&workspace).await?;
616 let what = if stop { "dispute: work stopped" } else { "dispute closed in the workspace's favour" };
617 self.audit(&account.id, "dispute", &format!("{workspace}: {what}"), "stripe").await?;
618 Ok(format!("{workspace}: {what}"))
619 }
620
621 // --- Enterprise invoices ------------------------------------------------
622
623 pub(crate) async fn admin_enterprise_billing(&self, a: AdminEnterpriseBillingArgs) -> Result<Outcome<BillingAccount>> {
624 let email = a.email.trim().to_lowercase();
625 if !email.contains('@') || email.contains(char::is_whitespace) || a.by.trim().is_empty() {
626 return Ok(Outcome::fail(FailureCode::Invalid, "Give the email the enterprise's invoices go to."));
627 }
628 let Some(stripe) = &self.stripe else {
629 return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t."));
630 };
631 #[derive(Deserialize)]
632 struct Row {
633 name: String,
634 kind: String,
635 customer_id: Option<String>,
636 }
637 let Some(row) = self
638 .db
639 .prepare("SELECT name, kind, customer_id FROM billing_accounts WHERE id = ?")
640 .bind(&[a.id.as_str().into()])?
641 .first::<Row>(None)
642 .await?
643 .filter(|row| row.kind == "enterprise")
644 else {
645 return Ok(Outcome::fail(FailureCode::NotFound, "No such enterprise."));
646 };
647 #[derive(Deserialize)]
648 struct Customer {
649 id: String,
650 }
651 let fields = [
652 ("name", row.name.clone()),
653 ("email", email.clone()),
654 ("metadata[g1t_enterprise]", a.id.clone()),
655 ];
656 let customer: Customer = match &row.customer_id {
657 Some(id) => stripe.post(&format!("/customers/{id}"), &fields).await?,
658 None => stripe.post("/customers", &fields).await?,
659 };
660 self.db
661 .prepare("UPDATE billing_accounts SET billing_email = ?, customer_id = ? WHERE id = ?")
662 .bind(&[email.as_str().into(), customer.id.as_str().into(), a.id.as_str().into()])?
663 .run()
664 .await?;
665 self.audit(&a.id, "billing_email", &format!("Invoices go to {email}"), &a.by).await?;
666 Ok(match self.enterprise(&a.id).await? {
667 Some(account) => Outcome::Ok(account),
668 None => Outcome::fail(FailureCode::NotFound, "No such enterprise."),
669 })
670 }
671
Merge Stripe Tax, the card fee on card payments, and one free workspace per person672 /// `admin_enterprise_address`: the address Stripe Tax works the
673 /// enterprise's invoices out from, and its tax ID, on its customer.
674 pub(crate) async fn admin_enterprise_address(&self, a: AdminEnterpriseAddressArgs) -> Result<Outcome<bool>> {
675 if a.by.trim().is_empty() {
676 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is changing it."));
677 }
678 if let Some(why) = crate::details::address_invalid(&a.address).or_else(|| crate::details::tax_id_invalid(a.tax_id_type.as_deref(), a.tax_id.as_deref())) {
679 return Ok(Outcome::fail(FailureCode::Invalid, why));
680 }
681 let place = serde_json::json!({ "country": a.address.country.trim(), "postal_code": a.address.postal_code.trim(), "state": a.address.state.trim() });
682 if !crate::stripe::address_places_customer(&place) {
683 return Ok(Outcome::fail(FailureCode::Invalid, "Give at least the country, and in the US the ZIP code: Stripe Tax needs them."));
684 }
685 let Some(stripe) = &self.stripe else {
686 return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t."));
687 };
688 #[derive(Deserialize)]
689 struct Row {
690 kind: String,
691 customer_id: Option<String>,
692 }
693 let Some(row) = self
694 .db
695 .prepare("SELECT kind, customer_id FROM billing_accounts WHERE id = ?")
696 .bind(&[a.id.as_str().into()])?
697 .first::<Row>(None)
698 .await?
699 .filter(|row| row.kind == "enterprise")
700 else {
701 return Ok(Outcome::fail(FailureCode::NotFound, "No such enterprise."));
702 };
703 let Some(customer) = row.customer_id else {
704 return Ok(Outcome::fail(FailureCode::Conflict, "Set where its invoices go first: that makes its Stripe customer."));
705 };
706 let fields: Vec<(&str, String)> = vec![
707 ("address[line1]", a.address.line1.trim().to_owned()),
708 ("address[line2]", a.address.line2.trim().to_owned()),
709 ("address[city]", a.address.city.trim().to_owned()),
710 ("address[state]", a.address.state.trim().to_owned()),
711 ("address[postal_code]", a.address.postal_code.trim().to_owned()),
712 ("address[country]", a.address.country.trim().to_uppercase()),
713 ];
714 if let Err(error) = stripe.post::<Value>(&format!("/customers/{customer}"), &fields).await {
715 return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error)));
716 }
717 if let (Some(kind), Some(value)) = (a.tax_id_type.as_deref(), a.tax_id.as_deref())
718 && let Err(why) = crate::details::replace_tax_id(stripe, &customer, &a.id, kind, value).await
719 {
720 return Ok(Outcome::fail(FailureCode::Invalid, why));
721 }
722 self.audit(&a.id, "billing_address", &format!("Billing address set ({})", a.address.country.trim().to_uppercase()), &a.by).await?;
723 Ok(Outcome::Ok(true))
724 }
725
Merge branch 'worktree-agent-a985c01f43538af01'726 pub(crate) async fn admin_invoice_enterprise(&self, a: AdminInvoiceEnterpriseArgs) -> Result<Outcome<EnterpriseInvoice>> {
727 if a.by.trim().is_empty() {
728 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is sending it."));
729 }
730 match self.invoice_enterprise(&a.id, "now", &a.by).await? {
731 Ok(invoice) => Ok(Outcome::Ok(invoice)),
732 Err(why) => Ok(Outcome::fail(FailureCode::Conflict, why)),
733 }
734 }
735
736 /// Invoices each enterprise for the month that closed. Live payments
737 /// only; sudo can send one sooner in test mode.
738 pub(crate) async fn invoice_enterprises(&self) -> Result<()> {
739 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
740 return Ok(());
741 }
742 let closing = crate::limits::previous_month(&rfc3339(now_ms())[..7]);
743 #[derive(Deserialize)]
744 struct Id {
745 id: String,
746 }
747 let due = self
748 .db
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging749 .prepare(format!(
Merge branch 'worktree-agent-a985c01f43538af01'750 "SELECT id FROM billing_accounts WHERE kind = 'enterprise' AND customer_id IS NOT NULL
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging751 AND NOT {full}
Merge branch 'worktree-agent-a985c01f43538af01'752 AND NOT EXISTS (SELECT 1 FROM enterprise_invoices i WHERE i.account_id = billing_accounts.id AND i.period = ?)",
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging753 full = crate::sales::FULL_DISCOUNT_SQL
754 ))
Merge branch 'worktree-agent-a985c01f43538af01'755 .bind(&[closing.as_str().into()])?
756 .all()
757 .await?
758 .results::<Id>()?;
759 for Id { id } in due {
760 if let Err(why) = self.invoice_enterprise(&id, &closing, "month close").await? {
761 worker::console_log!("enterprise {id} not invoiced for {closing}: {why}");
762 }
763 }
764 Ok(())
765 }
766
767 /// One invoice for what each of the enterprise's workspaces owes now.
768 async fn invoice_enterprise(&self, id: &str, period: &str, by: &str) -> Result<std::result::Result<EnterpriseInvoice, String>> {
769 let Some(stripe) = &self.stripe else { return Ok(Err("Payments are not set up.".into())) };
770 let Some(account) = self.enterprise(id).await? else { return Ok(Err("No such enterprise.".into())) };
771 #[derive(Deserialize)]
772 struct Customer {
773 customer_id: Option<String>,
774 }
775 let Some(customer) = self
776 .db
777 .prepare("SELECT customer_id FROM billing_accounts WHERE id = ?")
778 .bind(&[id.into()])?
779 .first::<Customer>(None)
780 .await?
781 .and_then(|row| row.customer_id)
782 else {
783 return Ok(Err("Set where the enterprise's invoices go first.".into()));
784 };
785 // What each workspace owes: its charges less what it has paid, and
786 // less what is on invoices still open.
787 let mut lines = vec![];
788 for workspace in &account.workspaces {
789 let balance = self.row(workspace).await?.map_or(0, |row| row.balance_micros);
790 #[derive(Deserialize)]
791 struct Sum {
792 micros: Option<i64>,
793 }
794 let invoiced = self
795 .db
796 .prepare(
797 "SELECT SUM(l.amount_micros) AS micros FROM enterprise_invoice_lines l
798 JOIN enterprise_invoices i ON i.invoice_id = l.invoice_id
799 WHERE l.workspace = ? AND i.status IN ('open', 'overdue')",
800 )
801 .bind(&[workspace.as_str().into()])?
802 .first::<Sum>(None)
803 .await?
804 .and_then(|s| s.micros)
805 .unwrap_or(0);
806 let owed = (-balance).max(0) - invoiced;
807 if owed >= 10_000 {
808 lines.push(InvoiceLine { workspace: workspace.clone(), amount_micros: owed });
809 }
810 }
811 if lines.is_empty() {
812 return Ok(Err("Its workspaces owe nothing to invoice.".into()));
813 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person814 // Stripe Tax places the customer by its address; without one the
815 // invoice could not be finalized, so it is not started.
816 match stripe.customer_placed(&customer).await {
817 Ok(true) => {}
818 Ok(false) => {
819 return Ok(Err(format!(
820 "Add {}'s billing address first (Invoices → Billing address): Stripe needs it to work out tax.",
821 account.name
822 )));
823 }
824 Err(error) => return Ok(Err(crate::stripe::friendly(&error))),
825 }
826 // Invoiced, never by card: no card fee. Tax on top, by Stripe Tax.
827 let mut fields = vec![
Merge branch 'worktree-agent-a985c01f43538af01'828 ("customer", customer.clone()),
829 ("collection_method", "send_invoice".to_owned()),
830 ("days_until_due", "30".to_owned()),
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging831 ("pending_invoice_items_behavior", "exclude".to_owned()),
Merge branch 'worktree-agent-a985c01f43538af01'832 ("description", format!("g1t usage for the {} enterprise", account.name)),
833 ("metadata[g1t_enterprise]", id.to_owned()),
834 ("metadata[period]", period.to_owned()),
835 ];
Merge Stripe Tax, the card fee on card payments, and one free workspace per person836 fields.extend(crate::stripe::invoice_tax_fields());
Merge branch 'worktree-agent-a985c01f43538af01'837 #[derive(Deserialize)]
838 struct Invoice {
839 id: String,
840 #[serde(default)]
841 hosted_invoice_url: Option<String>,
842 #[serde(default)]
843 amount_due: i64,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person844 /// The tax Stripe added, in cents (`tax`, in this API version).
845 #[serde(default)]
846 tax: Option<i64>,
Merge branch 'worktree-agent-a985c01f43538af01'847 }
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging848 // The draft first, keyed on what it bills, and its lines put on it:
849 // an attempt that failed half way is found again, never billed again
850 // by an invoice sweeping its pending lines in.
851 let cents: Vec<i64> = lines.iter().map(|line| (line.amount_micros + 9_999) / 10_000).collect();
852 let keyed: Vec<(&str, i64)> = lines.iter().zip(&cents).map(|(line, cents)| (line.workspace.as_str(), *cents)).collect();
853 let key = enterprise_invoice_key(id, period, &keyed);
854 let draft: Invoice = stripe.post_idempotent("/invoices", &fields, &key).await?;
855 for (line, cents) in lines.iter().zip(&cents) {
Merge Stripe Tax, the card fee on card payments, and one free workspace per person856 let mut fields = vec![
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging857 ("customer", customer.clone()),
858 ("invoice", draft.id.clone()),
859 ("amount", cents.to_string()),
860 ("currency", "usd".to_owned()),
861 ("description", format!("{}: g1t usage", line.workspace)),
862 ("metadata[workspace]", line.workspace.clone()),
863 ];
Merge Stripe Tax, the card fee on card payments, and one free workspace per person864 fields.extend(crate::stripe::item_tax_fields());
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging865 let _: Value = stripe.post_idempotent("/invoiceitems", &fields, &format!("{key}/item/{}", line.workspace)).await?;
866 }
867 // A retry finds it finalized already; that is fine.
868 let _ = stripe.post::<Value>(&format!("/invoices/{}/finalize", draft.id), &[]).await;
Merge Stripe Tax, the card fee on card payments, and one free workspace per person869 let sent: Invoice = match stripe.post(&format!("/invoices/{}/send", draft.id), &[]).await {
870 Ok(sent) => sent,
871 Err(error) if crate::stripe::is_tax_location_error(&error) => {
872 return Ok(Err(format!("Stripe could not work out tax for {}: add its billing address, then send it again.", account.name)));
873 }
874 Err(error) => return Err(error),
875 };
Merge branch 'worktree-agent-a985c01f43538af01'876 let now = rfc3339(now_ms());
Merge Stripe Tax, the card fee on card payments, and one free workspace per person877 // What the workspaces owe, before tax: the tax is the enterprise's
878 // to pay on top, never their usage, and is kept apart.
879 let tax_cents = sent.tax.unwrap_or(0).max(0);
880 let total = lines.iter().map(|l| l.amount_micros).sum::<i64>().max((sent.amount_due - tax_cents) * 10_000);
Merge branch 'worktree-agent-a985c01f43538af01'881 let mut writes = vec![self
882 .db
883 .prepare(
884 "INSERT INTO enterprise_invoices (invoice_id, account_id, period, amount_micros, status, hosted_url, created_by, created_at)
885 VALUES (?, ?, ?, ?, 'open', ?, ?, ?)",
886 )
887 .bind(&[
888 sent.id.as_str().into(),
889 id.into(),
890 period.into(),
891 (total as f64).into(),
892 crate::optional(sent.hosted_invoice_url.as_deref()),
893 by.into(),
894 now.as_str().into(),
895 ])?];
896 for line in &lines {
897 writes.push(
898 self.db
899 .prepare("INSERT INTO enterprise_invoice_lines (invoice_id, workspace, amount_micros) VALUES (?, ?, ?)")
900 .bind(&[sent.id.as_str().into(), line.workspace.as_str().into(), (line.amount_micros as f64).into()])?,
901 );
902 }
903 self.db.batch(writes).await?;
904 self.audit(id, "invoice", &format!("Invoice {} for {} sent ({period})", sent.id, crate::features::dollars(total)), by)
905 .await?;
906 Ok(Ok(EnterpriseInvoice {
907 invoice_id: sent.id,
908 hosted_url: sent.hosted_invoice_url,
909 amount_micros: total,
910 status: "open".to_owned(),
911 period: period.to_owned(),
912 lines,
913 created_at: now,
914 }))
915 }
916
917 /// An enterprise invoice paid: each workspace is credited its line, and
918 /// any stop for the invoice is lifted.
Merge Stripe Tax, the card fee on card payments, and one free workspace per person919 async fn enterprise_invoice_paid(&self, invoice: &Value) -> Result<String> {
920 let invoice_id = invoice["id"].as_str().unwrap_or_default();
Merge branch 'worktree-agent-a985c01f43538af01'921 let claimed = self
922 .db
923 .prepare(
924 "UPDATE enterprise_invoices SET status = 'paid', paid_at = ? WHERE invoice_id = ? AND status <> 'paid'
925 RETURNING invoice_id",
926 )
927 .bind(&[rfc3339(now_ms()).into(), invoice_id.into()])?
928 .first::<Value>(None)
929 .await?;
930 if claimed.is_none() {
931 return Ok("ignored: not an open enterprise invoice".to_owned());
932 }
933 let lines = self.invoice_lines(invoice_id).await?;
934 for line in &lines {
935 self.enter(
936 &line.workspace,
937 EntryKind::TopUp,
938 line.amount_micros,
939 &format!("Paid on the enterprise's invoice {invoice_id}"),
940 &format!("inv/{invoice_id}/{}", line.workspace),
941 None,
942 None,
943 None,
944 )
945 .await?;
946 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person947 // Its tax is the enterprise's, paid on top of what its workspaces
948 // owed, and never credited to them.
949 #[derive(Deserialize)]
950 struct Account {
951 account_id: String,
952 }
953 if let Some(account) = self
954 .db
955 .prepare("SELECT account_id FROM enterprise_invoices WHERE invoice_id = ?")
956 .bind(&[invoice_id.into()])?
957 .first::<Account>(None)
958 .await?
959 {
960 let extras = crate::tax::Extras { tax_cents: invoice["tax"].as_i64().unwrap_or(0).max(0), fee_cents: 0 };
961 self.record_extras(&account.account_id, invoice_id, invoice["payment_intent"].as_str(), extras, None).await?;
962 }
Merge branch 'worktree-agent-a985c01f43538af01'963 Ok(format!("invoice {invoice_id} paid; {} workspaces credited", lines.len()))
964 }
965
966 /// An enterprise invoice that went overdue stops its workspaces' work;
967 /// one voided is simply closed.
968 async fn enterprise_invoice_status(&self, invoice_id: &str, status: &str) -> Result<String> {
969 let updated = self
970 .db
971 .prepare("UPDATE enterprise_invoices SET status = ? WHERE invoice_id = ? AND status <> 'paid' RETURNING invoice_id")
972 .bind(&[status.into(), invoice_id.into()])?
973 .first::<Value>(None)
974 .await?;
975 if updated.is_none() {
976 return Ok("ignored: not an open enterprise invoice".to_owned());
977 }
978 if status == "overdue" {
979 let now = rfc3339(now_ms());
980 for line in self.invoice_lines(invoice_id).await? {
981 self.db
982 .prepare(
983 "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2)
984 ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2",
985 )
986 .bind(&[line.workspace.as_str().into(), now.as_str().into(), format!("the enterprise's invoice {invoice_id} is overdue").into()])?
987 .run()
988 .await?;
989 }
990 }
991 Ok(format!("invoice {invoice_id} is {status}"))
992 }
993
994 async fn invoice_lines(&self, invoice_id: &str) -> Result<Vec<InvoiceLine>> {
995 Ok(self
996 .db
997 .prepare("SELECT workspace, amount_micros FROM enterprise_invoice_lines WHERE invoice_id = ?")
998 .bind(&[invoice_id.into()])?
999 .all()
1000 .await?
1001 .results::<LineRow>()?
1002 .into_iter()
1003 .map(|row| InvoiceLine { workspace: row.workspace, amount_micros: row.amount_micros })
1004 .collect())
1005 }
1006
1007 /// An enterprise's invoices, newest first.
1008 pub(crate) async fn enterprise_invoices(&self, id: &str) -> Result<Vec<EnterpriseInvoice>> {
1009 let rows = self
1010 .db
1011 .prepare("SELECT * FROM enterprise_invoices WHERE account_id = ? ORDER BY created_at DESC LIMIT 24")
1012 .bind(&[JsValue::from(id)])?
1013 .all()
1014 .await?
1015 .results::<InvoiceRow>()?;
1016 let mut invoices = vec![];
1017 for row in rows {
1018 invoices.push(EnterpriseInvoice {
1019 lines: self.invoice_lines(&row.invoice_id).await?,
1020 invoice_id: row.invoice_id,
1021 hosted_url: row.hosted_url,
1022 amount_micros: row.amount_micros,
1023 status: row.status,
1024 period: row.period,
1025 created_at: row.created_at,
1026 });
1027 }
1028 Ok(invoices)
1029 }
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034 use super::*;
1035
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging1036 #[test]
1037 fn an_enterprise_invoice_attempted_again_is_the_same_invoice() {
1038 let lines = [("acme", 1_250), ("acme-labs", 99)];
1039 let key = enterprise_invoice_key("ent_1", "2026-09", &lines);
1040 assert_eq!(key, enterprise_invoice_key("ent_1", "2026-09", &lines));
1041 assert!(key.len() < 255 && key.starts_with("ent-invoice/ent_1/2026-09/"));
1042 // Different amounts, even with the same total, are a different invoice.
1043 assert_ne!(key, enterprise_invoice_key("ent_1", "2026-09", &[("acme", 1_300), ("acme-labs", 49)]));
1044 assert_ne!(key, enterprise_invoice_key("ent_1", "2026-10", &lines));
1045 }
1046
Merge branch 'worktree-agent-a985c01f43538af01'1047 fn sign(payload: &str, secret: &str, t: i64) -> String {
1048 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
1049 mac.update(format!("{t}.{payload}").as_bytes());
1050 format!("t={t},v1={}", hex::encode(mac.finalize().into_bytes()))
1051 }
1052
1053 #[test]
1054 fn a_destination_misses_the_events_billing_handles_that_it_does_not_send() {
1055 let has: Vec<String> = EVENTS.iter().take(11).map(|e| (*e).to_owned()).collect();
1056 assert_eq!(missing_events(&has), EVENTS[11..].iter().map(|e| (*e).to_owned()).collect::<Vec<_>>());
1057 let all: Vec<String> = EVENTS.iter().map(|e| (*e).to_owned()).collect();
1058 assert!(missing_events(&all).is_empty());
1059 assert!(missing_events(&["*".to_owned()]).is_empty());
1060 }
1061
1062 #[test]
1063 fn a_signed_event_is_believed_only_as_signed_and_only_fresh() {
1064 let payload = r#"{"id":"evt_1","type":"invoice.paid"}"#;
1065 let header = sign(payload, "whsec_test", 1_000_000);
1066 assert!(verify(payload, &header, "whsec_test", 1_000_010));
1067 assert!(!verify(payload, &header, "whsec_other", 1_000_010));
1068 assert!(!verify(&payload.replace("paid", "voided"), &header, "whsec_test", 1_000_010));
1069 assert!(!verify(payload, &header, "whsec_test", 1_000_000 + 301));
1070 assert!(!verify(payload, "v1=abc", "whsec_test", 1_000_000));
1071 // Stripe may sign with more than one secret while one is rolled.
1072 let both = format!("{},v1=00ff", sign(payload, "whsec_test", 1_000_000));
1073 assert!(verify(payload, &both, "whsec_test", 1_000_000));
1074 }
1075}

This file's history is long; its oldest lines are credited to the oldest commit read.