Skip to content
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.

Topics sit in About on the files pages, under the description1//! 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,
Topics sit in About on the files pages, under the description26 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
Topics sit in About on the files pages, under the description74/// 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
Topics sit in About on the files pages, under the description161impl 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?;
Topics sit in About on the files pages, under the description300 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?
Topics sit in About on the files pages, under the description305 }
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()) {
Topics sit in About on the files pages, under the description308 (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>,
Topics sit in About on the files pages, under the description346 }
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'")
Topics sit in About on the files pages, under the description350 .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 }
Topics sit in About on the files pages, under the description368 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?;
Topics sit in About on the files pages, under the description385 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;
Topics sit in About on the files pages, under the description416 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 }
Topics sit in About on the files pages, under the description433 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() {
Topics sit in About on the files pages, under the description538 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")))
Topics sit in About on the files pages, under the description576 }
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
Topics sit in About on the files pages, under the description726 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!(
Topics sit in About on the files pages, under the description750 "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}
Topics sit in About on the files pages, under the description752 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 ))
Topics sit in About on the files pages, under the description755 .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![
Topics sit in About on the files pages, under the description828 ("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()),
Topics sit in About on the files pages, under the description832 ("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());
Topics sit in About on the files pages, under the description837 #[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>,
Topics sit in About on the files pages, under the description847 }
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 };
Topics sit in About on the files pages, under the description876 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);
Topics sit in About on the files pages, under the description881 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();
Topics sit in About on the files pages, under the description921 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 }
Topics sit in About on the files pages, under the description963 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
Topics sit in About on the files pages, under the description1047 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.