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

Mission control: a place to ask g1t, shown and not on yet, with the actions under it1//! 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
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.12//! mode. Its signing secret is the worker's `STRIPE_WEBHOOK_SECRET`
13//! environment secret, never kept in the database or shown, and nothing
14//! can be posted here without it.
15//! - An event whose `livemode` is not the key's mode is ignored (and
16//! logged): test events never move live balances, nor the reverse.
Mission control: a place to ask g1t, shown and not on yet, with the actions under it17//! - Each event is handled once, by id, and recorded with what was done.
18//! - Every handler is safe alongside the paths that ask Stripe directly:
19//! both claim the same rows.
20//!
21//! Enterprises are invoiced: one Stripe invoice per month (or sooner, from
22//! sudo), with a line per workspace for what it owes, sent to the
23//! enterprise's billing email and paid on Stripe's hosted invoice page.
24//! When it is paid, each workspace is credited its line; when it goes
25//! overdue, their work stops until it is paid.
26
27use g1t_contracts::billing::{
Merge Stripe Tax, the card fee on card payments, and one free workspace per person28 AdminEnterpriseAddressArgs, AdminEnterpriseBillingArgs, AdminInvoiceEnterpriseArgs, AdminStripeArgs, BillingAccount, EnterpriseInvoice,
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.29 EntryKind, InvoiceLine, StripeEventSummary, StripePortalStatus, StripeStatus, StripeTaxStatus, StripeWebhook, StripeWebhookArgs,
Mission control: a place to ask g1t, shown and not on yet, with the actions under it30};
31use g1t_contracts::time::rfc3339;
32use g1t_contracts::{FailureCode, Outcome};
33use g1t_kit::now_ms;
34use hmac::{Hmac, Mac};
35use serde::Deserialize;
36use serde_json::Value;
37use sha2::Sha256;
38use worker::Result;
39use worker::wasm_bindgen::JsValue;
40
41use crate::Billing;
42
43/// Where Stripe sends events.
44pub(crate) const WEBHOOK_URL: &str = "https://api.g1t.sh/stripe/webhook";
45
46/// The events billing acts on.
47pub(crate) const EVENTS: &[&str] = &[
48 "checkout.session.completed",
49 // A prepayment by bank transfer: the page completes when the transfer
50 // is set up, and this comes when the money arrives.
51 "checkout.session.async_payment_succeeded",
52 "customer.subscription.updated",
53 "customer.subscription.deleted",
54 "invoice.paid",
55 "invoice.payment_failed",
56 "invoice.overdue",
57 "invoice.voided",
58 "charge.refunded",
59 "charge.dispute.created",
60 "charge.dispute.closed",
61 // The saved card, kept on the account row (stripe_sync.rs).
62 "customer.updated",
63 "payment_method.attached",
64 "payment_method.detached",
65 "payment_method.updated",
66 "payment_method.automatically_updated",
67 "setup_intent.succeeded",
68];
69
Usage, Billing settings and prepaid AI credit; fixes from the UX audit70/// The subscription an invoice is for: `subscription` at the API version
71/// billing asks in, `parent.subscription_details.subscription` in events
72/// sent at a newer one (a destination keeps the version it was made at).
73pub(crate) fn invoice_subscription(invoice: &Value) -> Option<&str> {
74 invoice["subscription"].as_str().or_else(|| invoice["parent"]["subscription_details"]["subscription"].as_str())
75}
76
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.77/// Why an event is not for this key, when its `livemode` disagrees with the
78/// key's mode (`live` or `test`): a test event never moves real balances,
79/// nor a live one test balances. None when it agrees, or says nothing.
80pub(crate) fn wrong_mode(livemode: Option<bool>, mode: &str) -> Option<String> {
81 let live = livemode?;
82 let key_live = mode == "live";
83 (live != key_live).then(|| format!("a {} event, and the key is in {mode} mode", if live { "live" } else { "test" }))
84}
85
86/// What earlier refunds of a charge (`?1`, `refund/<charge>/%`) took back,
87/// as a positive amount: from the plan's payments and from the ledger alike.
88pub(crate) const ALREADY_REFUNDED_SQL: &str = "SELECT -(COALESCE((SELECT SUM(amount_micros) FROM plan_payments WHERE invoice_id LIKE ?1), 0)
89 + COALESCE((SELECT SUM(amount_micros) FROM ledger WHERE reference LIKE ?1), 0)) AS micros";
90
91/// Why a workspace's work is stopped while one of its payments is disputed.
92const DISPUTE_STOP: &str = "a payment was disputed with the card's bank";
93
94/// Where a dispute stands, from Stripe's `status`: `lost`, `won` (a
95/// warning closed without a dispute counts as won), else `open`.
96pub(crate) fn dispute_state(status: &str) -> &'static str {
97 match status {
98 "lost" => "lost",
99 "won" | "warning_closed" | "prevented" => "won",
100 _ => "open",
101 }
102}
103
104/// Whether a dispute event moves the dispute from where billing has it
105/// (`known`, none for a dispute not seen before) to `state`: an event that
106/// says what billing knows already does nothing. Once lost, only a win
107/// (Stripe reversing it) moves it again; once detached (a test-mode
108/// dispute), nothing does.
109pub(crate) fn dispute_moves(known: Option<&str>, state: &str) -> bool {
110 match known {
111 None => true,
112 Some(known) if known == state => false,
113 // Closed by a test-mode detach: a test dispute, never acted on.
114 Some("detached") => false,
115 Some("lost") => state == "won",
116 Some(_) => true,
117 }
118}
119
120/// What a dispute won after it was lost gives back: what the loss took
121/// (`taken`), only on that move. Nothing for any other.
122pub(crate) fn restore_on_win(known: Option<&str>, state: &str, taken: i64) -> i64 {
123 if known == Some("lost") && state == "won" { taken.max(0) } else { 0 }
124}
125
126/// Where a lost dispute's reversal is given back: `dispute/<id>/won`,
127/// unique, so it is given back once.
128pub(crate) fn won_reference(dispute_id: &str) -> String {
129 format!("dispute/{dispute_id}/won")
130}
131
132/// What a dispute won after it was lost no longer leaves owed: `?1` the
133/// workspace, `?2` what the loss took. Never below nothing.
134pub(crate) const LOWER_DISPUTE_OWED: &str =
135 "UPDATE limits SET dispute_owed_micros = MAX(0, dispute_owed_micros - ?2) WHERE workspace = ?1";
136
Mission control: a place to ask g1t, shown and not on yet, with the actions under it137/// Events billing handles that `has` does not include, in billing's order.
138pub(crate) fn missing_events(has: &[String]) -> Vec<String> {
139 // `*` is every event.
140 if has.iter().any(|event| event == "*") {
141 return Vec::new();
142 }
143 EVENTS.iter().filter(|event| !has.iter().any(|h| h == *event)).map(|event| (*event).to_owned()).collect()
144}
145
146/// How old a signed event may be, so a captured one cannot be replayed.
147const TOLERANCE_SECONDS: i64 = 5 * 60;
148
149/// Whether `header` (`t=…,v1=…`) signs `payload` with `secret`, within the
150/// tolerance of `now_seconds`.
151pub(crate) fn verify(payload: &str, header: &str, secret: &str, now_seconds: i64) -> bool {
152 let mut timestamp = None;
153 let mut signatures = vec![];
154 for part in header.split(',') {
155 match part.trim().split_once('=') {
156 Some(("t", value)) => timestamp = value.parse::<i64>().ok(),
157 Some(("v1", value)) => signatures.push(value.to_owned()),
158 _ => {}
159 }
160 }
161 let Some(timestamp) = timestamp else { return false };
162 if (now_seconds - timestamp).abs() > TOLERANCE_SECONDS {
163 return false;
164 }
165 let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else { return false };
166 mac.update(format!("{timestamp}.{payload}").as_bytes());
167 let expected = mac.finalize().into_bytes();
168 signatures.iter().any(|signature| {
169 hex::decode(signature).is_ok_and(|given| {
170 // Constant time: compare every byte whatever the first difference.
171 given.len() == expected.len() && given.iter().zip(expected.iter()).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0
172 })
173 })
174}
175
176/// The destination at billing's address, as Stripe lists it.
177#[derive(Deserialize)]
178pub(crate) struct Destination {
179 pub(crate) id: String,
180 url: String,
181 /// `enabled` or `disabled`.
182 pub(crate) status: String,
183 pub(crate) enabled_events: Vec<String>,
184 created: i64,
185}
186
187#[derive(Deserialize)]
188struct EventRow {
189 id: String,
190 r#type: String,
191 outcome: String,
192 received_at: String,
193}
194
195#[derive(Deserialize)]
196struct InvoiceRow {
197 invoice_id: String,
198 period: String,
199 amount_micros: i64,
200 status: String,
201 hosted_url: Option<String>,
202 created_at: String,
203}
204
205#[derive(Deserialize)]
206struct LineRow {
207 workspace: String,
208 amount_micros: i64,
209}
210
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging211/// The idempotency key of an enterprise's invoice: the same enterprise,
212/// period and amounts are the same invoice however often it is attempted.
213pub(crate) fn enterprise_invoice_key(id: &str, period: &str, lines: &[(&str, i64)]) -> String {
214 // FNV-1a over every line, so the key stays short however many there are.
215 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
216 for (workspace, cents) in lines {
217 for byte in format!("{workspace}={cents};").bytes() {
218 hash = (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3);
219 }
220 }
221 format!("ent-invoice/{id}/{period}/{hash:016x}")
222}
223
Mission control: a place to ask g1t, shown and not on yet, with the actions under it224impl Billing {
225 pub(crate) fn mode(&self) -> &'static str {
226 match &self.stripe {
227 None => "off",
228 Some(stripe) if stripe.live() => "live",
229 Some(_) => "test",
230 }
231 }
232
233 /// The destination at billing's address in Stripe, for this key's
234 /// mode: an enabled one first. None when there is none.
235 pub(crate) async fn destination(&self) -> Result<Option<Destination>> {
236 let Some(stripe) = &self.stripe else { return Ok(None) };
237 #[derive(Deserialize)]
238 struct List {
239 data: Vec<Destination>,
240 }
241 let mut ours: Vec<Destination> =
242 stripe.get::<List>("/webhook_endpoints?limit=100").await?.data.into_iter().filter(|d| d.url == WEBHOOK_URL).collect();
243 ours.sort_by_key(|d| d.status != "enabled");
244 Ok(ours.into_iter().next())
245 }
246
247 // --- Staff ------------------------------------------------------------
248
249 pub(crate) async fn admin_stripe(&self, a: AdminStripeArgs) -> Result<StripeStatus> {
250 let mut error = None;
251 if a.fix
252 && let Err(e) = self.keep_endpoint(a.by.as_deref().unwrap_or("sudo")).await
253 {
254 error = Some(e.to_string());
255 }
256 let (webhook, missing_events) = match self.destination().await {
257 Ok(Some(d)) => {
258 let missing = missing_events(&d.enabled_events);
259 let webhook = StripeWebhook {
260 url: d.url,
261 endpoint_id: d.id,
262 status: d.status,
263 events: d.enabled_events,
264 created_at: rfc3339(d.created.max(0) as u64 * 1000),
265 };
266 (Some(webhook), missing)
267 }
268 Ok(None) => (None, Vec::new()),
269 Err(e) => {
270 error = error.or(Some(format!("Stripe could not be read: {e}")));
271 (None, Vec::new())
272 }
273 };
274 let recent_events = self
275 .db
276 .prepare("SELECT * FROM stripe_events ORDER BY received_at DESC LIMIT 25")
277 .all()
278 .await?
279 .results::<EventRow>()?
280 .into_iter()
281 .map(|row| StripeEventSummary { id: row.id, kind: row.r#type, outcome: row.outcome, received_at: row.received_at })
282 .collect();
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.283 // Stripe Tax and the customer portal, for the go-live checklist:
284 // read as Stripe has them, never made or changed here.
285 let (mut tax, mut portal) = (None, None);
286 if let Some(stripe) = &self.stripe {
287 match stripe.tax_settings().await {
288 Ok(settings) => {
289 tax = Some(StripeTaxStatus {
290 status: settings.status,
291 missing_fields: settings.status_details.pending.and_then(|p| p.missing_fields).unwrap_or_default(),
292 })
293 }
294 Err(e) => error = error.or(Some(format!("Stripe Tax's settings could not be read: {e}"))),
295 }
296 match stripe.our_portal().await {
297 Ok(found) => portal = found.map(|c| StripePortalStatus { configuration_id: c.id, active: c.active }),
298 Err(e) => error = error.or(Some(format!("The customer portal's settings could not be read: {e}"))),
299 }
300 }
Mission control: a place to ask g1t, shown and not on yet, with the actions under it301 Ok(StripeStatus {
302 mode: self.mode().to_owned(),
303 secret_set: self.webhook_secret.is_some(),
304 webhook,
305 missing_events,
306 recent_events,
307 error,
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.308 tax,
309 portal,
Mission control: a place to ask g1t, shown and not on yet, with the actions under it310 })
311 }
312
313 // --- Events -----------------------------------------------------------
314
315 pub(crate) async fn stripe_webhook(&self, a: StripeWebhookArgs) -> Result<Outcome<bool>> {
316 let Some(secret) = &self.webhook_secret else {
317 return Ok(Outcome::fail(
318 FailureCode::Conflict,
319 "STRIPE_WEBHOOK_SECRET is not set, so billing cannot check that events come from Stripe.",
320 ));
321 };
322 let now_seconds = (now_ms() / 1000) as i64;
323 if !verify(&a.payload, &a.signature, secret, now_seconds) {
324 return Ok(Outcome::fail(FailureCode::Forbidden, "The signature does not match."));
325 }
326 let event: Value = serde_json::from_str(&a.payload).map_err(|e| worker::Error::RustError(e.to_string()))?;
327 if event["id"].as_str().unwrap_or_default().is_empty() {
328 return Ok(Outcome::fail(FailureCode::Invalid, "Not an event."));
329 }
330 Ok(Outcome::Ok(self.process_event(&event).await?))
331 }
332
333 /// Handles a Stripe event once, however often it arrives: by webhook,
334 /// or again from the event list (stripe_sync.rs). False when it was
335 /// seen before.
336 pub(crate) async fn process_event(&self, event: &Value) -> Result<bool> {
337 let id = event["id"].as_str().unwrap_or_default().to_owned();
338 let kind = event["type"].as_str().unwrap_or_default().to_owned();
339 // Once each: the first to record it handles it.
340 let claimed = self
341 .db
342 .prepare("INSERT OR IGNORE INTO stripe_events (id, type, outcome, received_at) VALUES (?, ?, 'handling', ?) RETURNING id")
343 .bind(&[id.as_str().into(), kind.as_str().into(), rfc3339(now_ms()).into()])?
344 .first::<Value>(None)
345 .await?;
346 if claimed.is_none() {
347 return Ok(false);
348 }
349 let outcome = match self.handle(&kind, event).await {
350 Ok(outcome) => outcome,
351 Err(error) => {
352 // Let Stripe send it again: forget it was seen.
353 self.db.prepare("DELETE FROM stripe_events WHERE id = ?").bind(&[id.as_str().into()])?.run().await?;
354 return Err(error);
355 }
356 };
357 self.db
358 .prepare("UPDATE stripe_events SET outcome = ? WHERE id = ?")
359 .bind(&[outcome.as_str().into(), id.as_str().into()])?
360 .run()
361 .await?;
362 Ok(true)
363 }
364
365 async fn handle(&self, kind: &str, event: &Value) -> Result<String> {
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.366 if let Some(why) = wrong_mode(event["livemode"].as_bool(), self.mode()) {
367 worker::console_error!("Stripe event {} ({kind}) ignored: {why}", event["id"].as_str().unwrap_or_default());
368 return Ok(format!("ignored: {why}"));
369 }
Mission control: a place to ask g1t, shown and not on yet, with the actions under it370 let object = &event["data"]["object"];
371 let text = |key: &str| object[key].as_str().unwrap_or_default().to_owned();
372 // The customer whose saved card may have changed. A detached card
373 // has no customer any more; the event says whose it was.
374 let card_owner = match kind {
375 "customer.updated" => object["id"].as_str(),
376 "payment_method.detached" => event["data"]["previous_attributes"]["customer"].as_str(),
377 _ => object["customer"].as_str(),
378 };
379 Ok(match kind {
380 "checkout.session.completed" | "checkout.session.async_payment_succeeded" => self.settle_checkout(&text("id")).await?,
381 "customer.subscription.updated" | "customer.subscription.deleted" => {
382 self.settle_subscription(&text("id")).await?
383 }
384 "invoice.paid" => {
Usage, Billing settings and prepaid AI credit; fixes from the UX audit385 if let Some(subscription) = invoice_subscription(object) {
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.386 self.plan_paid(subscription, &text("id")).await?;
Mission control: a place to ask g1t, shown and not on yet, with the actions under it387 self.settle_subscription(subscription).await?
388 } else if let Some(done) = self.workspace_invoice_paid(&text("id")).await? {
389 done
390 } else {
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.391 self.enterprise_invoice_paid(&text("id")).await?
Mission control: a place to ask g1t, shown and not on yet, with the actions under it392 }
393 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit394 "invoice.payment_failed" => match (invoice_subscription(object), object["metadata"]["g1t_workspace"].as_str()) {
Mission control: a place to ask g1t, shown and not on yet, with the actions under it395 (Some(subscription), _) => self.settle_subscription(subscription).await?,
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.396 (None, Some(tagged)) => self.workspace_invoice_failed(&text("id"), tagged).await?,
Mission control: a place to ask g1t, shown and not on yet, with the actions under it397 _ => "ignored: not g1t's".to_owned(),
398 },
399 "invoice.overdue" => self.enterprise_invoice_status(&text("id"), "overdue").await?,
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.400 "invoice.voided" => {
401 // A workspace invoice voided at Stripe stops being open here.
402 self.db
403 .prepare("UPDATE workspace_invoices SET status = 'void' WHERE invoice_id = ? AND status IN ('open', 'failed')")
404 .bind(&[text("id").into()])?
405 .run()
406 .await?;
407 self.enterprise_invoice_status(&text("id"), "void").await?
408 }
409 "charge.refunded" => self.refunded(&text("id")).await?,
410 // Created or closed, the dispute is read as it stands now, so
411 // events late or out of order act on where it is.
412 "charge.dispute.created" | "charge.dispute.closed" => self.disputed(&text("id")).await?,
Mission control: a place to ask g1t, shown and not on yet, with the actions under it413 "customer.updated"
414 | "payment_method.attached"
415 | "payment_method.detached"
416 | "payment_method.updated"
417 | "payment_method.automatically_updated"
418 | "setup_intent.succeeded" => match card_owner {
419 Some(customer) => self.sync_card_of(customer).await?,
420 None => "ignored: no customer".to_owned(),
421 },
422 _ => "ignored".to_owned(),
423 })
424 }
425
426 /// A payment page done, whether or not the person came back to g1t.
427 async fn settle_checkout(&self, session_id: &str) -> Result<String> {
428 #[derive(Deserialize)]
429 struct Open {
430 workspace: String,
431 created_by: String,
432 feature: Option<String>,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person433 #[serde(default)]
434 fee_cents: Option<u32>,
Mission control: a place to ask g1t, shown and not on yet, with the actions under it435 }
436 let Some(open) = self
437 .db
Merge Stripe Tax, the card fee on card payments, and one free workspace per person438 .prepare("SELECT workspace, created_by, feature, fee_cents FROM checkouts WHERE id = ? AND status = 'open'")
Mission control: a place to ask g1t, shown and not on yet, with the actions under it439 .bind(&[session_id.into()])?
440 .first::<Open>(None)
441 .await?
442 else {
443 return Ok("ignored: already settled or not g1t's".to_owned());
444 };
445 // A card check: saved and verified, never charged.
446 if open.feature.as_deref() == Some(crate::cards::CARD_CHECK) {
447 return Ok(match self.settle_card_check(session_id).await? {
448 Ok(done) => done,
449 Err(why) => format!("card check not passed: {why}"),
450 });
451 }
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.452 // AI credit, from before g1t stopped selling it: a page left open
453 // then is never credited as anything else.
454 if open.feature.as_deref() == Some("ai_credit") {
455 return Ok("ignored: AI credit is no longer sold".to_owned());
Usage, Billing settings and prepaid AI credit; fixes from the UX audit456 }
Mission control: a place to ask g1t, shown and not on yet, with the actions under it457 let Some(stripe) = &self.stripe else { return Ok("ignored: payments off".to_owned()) };
458 let session = stripe.session(session_id).await?;
459 if session.payment_status != "paid" && open.feature.is_none() {
460 return Ok("ignored: not paid".to_owned());
461 }
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.462 // What the page paid for first, then the page marked paid: a
463 // failure between the two leaves it open, and settling it again
464 // finds the credit (the ledger's reference) or the plan entered.
465 let mark = self.db.prepare("UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open'").bind(&[session_id.into()])?;
Mission control: a place to ask g1t, shown and not on yet, with the actions under it466 match open.feature.as_deref() {
467 None => {
Merge Stripe Tax, the card fee on card payments, and one free workspace per person468 let cents = self.credit_prepayment(&open.workspace, &session, open.fee_cents.unwrap_or(0), &open.created_by).await?;
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.469 mark.run().await?;
470 if cents == 0 {
471 return Ok("ignored: credited meanwhile".to_owned());
472 }
Mission control: a place to ask g1t, shown and not on yet, with the actions under it473 Ok(format!("credited {} to {}", crate::features::dollars(cents * 10_000), open.workspace))
474 }
475 Some(feature) => {
476 let Some(feature) = g1t_contracts::billing::Feature::parse(feature) else {
477 return Ok("ignored: unknown feature".to_owned());
478 };
479 if let Some(subscription_id) = &session.subscription {
480 let subscription = stripe.subscription(subscription_id).await?;
481 self.record(&open.workspace, feature, &subscription, &open.created_by).await?;
482 }
483 self.db
484 .prepare(
485 "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at) VALUES (?1, 0, ?2, ?3)
486 ON CONFLICT (workspace) DO UPDATE SET customer_id = COALESCE(customer_id, ?2)",
487 )
488 .bind(&[open.workspace.as_str().into(), crate::optional(session.customer.as_deref()), rfc3339(now_ms()).into()])?
489 .run()
490 .await?;
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.491 mark.run().await?;
Mission control: a place to ask g1t, shown and not on yet, with the actions under it492 Ok(format!("{} plan started for {}", feature.title(), open.workspace))
493 }
494 }
495 }
496
497 /// The plan's monthly price, paid: revenue that never goes through the
498 /// 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 person499 /// Only the plan's own price is revenue: the invoice's tax and card fee
500 /// are kept apart (`tax_and_fees`).
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.501 ///
502 /// The invoice is read again at billing's API version: the event's copy
503 /// is at the destination's, where `tax` and `payment_intent` may be
504 /// gone.
505 async fn plan_paid(&self, subscription_id: &str, invoice_id: &str) -> Result<()> {
506 let Some(stripe) = &self.stripe else { return Ok(()) };
507 if invoice_id.is_empty() {
508 return Ok(());
509 }
510 let invoice: Value = stripe.get(&format!("/invoices/{invoice_id}")).await?;
511 let invoice = &invoice;
Merge Stripe Tax, the card fee on card payments, and one free workspace per person512 let split = crate::stripe::invoice_split(invoice, invoice["amount_paid"].as_i64().unwrap_or(0));
513 let amount_cents = split.net_cents;
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.514 if amount_cents <= 0 {
Mission control: a place to ask g1t, shown and not on yet, with the actions under it515 return Ok(());
516 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person517 #[derive(Deserialize)]
518 struct Owner {
519 workspace: String,
520 }
521 if let Some(owner) = self
522 .db
523 .prepare("SELECT workspace FROM subscriptions WHERE subscription_id = ?")
524 .bind(&[subscription_id.into()])?
525 .first::<Owner>(None)
526 .await?
527 {
528 let extras = crate::tax::Extras { tax_cents: split.tax_cents, fee_cents: split.fee_cents };
529 self.record_extras(&owner.workspace, invoice_id, invoice["payment_intent"].as_str(), extras, None).await?;
530 }
Mission control: a place to ask g1t, shown and not on yet, with the actions under it531 self.db
532 .prepare(
533 "INSERT INTO plan_payments (invoice_id, workspace, amount_micros, paid_at)
534 SELECT ?1, workspace, ?2, ?3 FROM subscriptions WHERE subscription_id = ?4
535 ON CONFLICT (invoice_id) DO NOTHING",
536 )
537 .bind(&[
538 invoice_id.into(),
539 ((amount_cents * 10_000) as f64).into(),
540 rfc3339(now_ms()).into(),
541 subscription_id.into(),
542 ])?
543 .run()
544 .await?;
545 Ok(())
546 }
547
548 /// A plan that changed at Stripe: renewed, failed, canceled.
549 pub(crate) async fn settle_subscription(&self, subscription_id: &str) -> Result<String> {
550 #[derive(Deserialize)]
551 struct Plan {
552 workspace: String,
553 feature: String,
554 started_by: String,
555 }
556 let Some(plan) = self
557 .db
558 .prepare("SELECT workspace, feature, started_by FROM subscriptions WHERE subscription_id = ?")
559 .bind(&[subscription_id.into()])?
560 .first::<Plan>(None)
561 .await?
562 else {
563 return Ok("ignored: not a g1t plan".to_owned());
564 };
565 let (Some(stripe), Some(feature)) = (&self.stripe, g1t_contracts::billing::Feature::parse(&plan.feature)) else {
566 return Ok("ignored".to_owned());
567 };
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.568 let subscription = match stripe.subscription(subscription_id).await {
569 Ok(subscription) => subscription,
570 // A plan Stripe no longer has (another account's, or deleted):
571 // it has ended here.
572 Err(error) if crate::stripe::is_missing(&error) => {
573 self.db
574 .prepare("UPDATE subscriptions SET status = 'canceled', updated_at = ? WHERE subscription_id = ?")
575 .bind(&[rfc3339(now_ms()).into(), subscription_id.into()])?
576 .run()
577 .await?;
578 return Ok(format!("{} plan for {} is gone at Stripe: canceled", feature.title(), plan.workspace));
579 }
580 Err(error) => return Err(error),
581 };
Mission control: a place to ask g1t, shown and not on yet, with the actions under it582 self.record(&plan.workspace, feature, &subscription, &plan.started_by).await?;
583 Ok(format!("{} plan for {} is {}", feature.title(), plan.workspace, subscription.status))
584 }
585
586 /// The workspace a Stripe customer belongs to.
587 pub(crate) async fn workspace_of_customer(&self, customer: &str) -> Result<Option<String>> {
588 #[derive(Deserialize)]
589 struct Row {
590 workspace: String,
591 }
592 Ok(self
593 .db
594 .prepare("SELECT workspace FROM accounts WHERE customer_id = ?")
595 .bind(&[customer.into()])?
596 .first::<Row>(None)
597 .await?
598 .map(|row| row.workspace))
599 }
600
601 /// The workspace a workspace invoice was sent to, under its slug now.
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.602 pub(crate) async fn workspace_of_invoice(&self, invoice_id: &str) -> Result<Option<String>> {
Mission control: a place to ask g1t, shown and not on yet, with the actions under it603 #[derive(Deserialize)]
604 struct Row {
605 workspace: String,
606 }
607 Ok(self
608 .db
609 .prepare("SELECT workspace FROM workspace_invoices WHERE invoice_id = ?")
610 .bind(&[invoice_id.into()])?
611 .first::<Row>(None)
612 .await?
613 .map(|row| row.workspace))
614 }
615
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.616 /// Money given back: what was paid is less, by the refund. A refund of
617 /// a plan's invoice takes back the plan's payment (`plan_payments`);
618 /// any other takes back credit on the balance. The charge is read again
619 /// at billing's API version, where it still carries `invoice`.
620 async fn refunded(&self, charge_id: &str) -> Result<String> {
621 let Some(stripe) = &self.stripe else { return Ok("ignored: payments off".to_owned()) };
622 let charge: Value = stripe.get(&format!("/charges/{charge_id}")).await?;
623 let charge = &charge;
Mission control: a place to ask g1t, shown and not on yet, with the actions under it624 let Some(customer) = charge["customer"].as_str() else { return Ok("ignored: no customer".to_owned()) };
625 let Some(workspace) = self.workspace_of_customer(customer).await? else {
626 return Ok("ignored: not a workspace's customer".to_owned());
627 };
628 let refunded = charge["amount_refunded"].as_i64().unwrap_or(0);
629 if refunded <= 0 {
630 return Ok("ignored: nothing refunded".to_owned());
631 }
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.632 let plan_invoice = match charge["invoice"].as_str() {
633 Some(invoice) => self.plan_payment_of(invoice).await?,
634 None => false,
635 };
636 // Each refund total once, so partial refunds add up correctly: what
637 // earlier refunds of the charge took back, wherever they were kept.
638 // A plan's refund recorded on the ledger (before plan refunds went
639 // to `plan_payments`) counts as much as one recorded there.
Mission control: a place to ask g1t, shown and not on yet, with the actions under it640 #[derive(Deserialize)]
641 struct Sum {
642 micros: Option<i64>,
643 }
644 let already = self
645 .db
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.646 .prepare(ALREADY_REFUNDED_SQL)
Mission control: a place to ask g1t, shown and not on yet, with the actions under it647 .bind(&[format!("refund/{charge_id}/%").into()])?
648 .first::<Sum>(None)
649 .await?
650 .and_then(|s| s.micros)
651 .unwrap_or(0);
Merge Stripe Tax, the card fee on card payments, and one free workspace per person652 // A refund gives back the payment's tax and card fee in proportion:
653 // only the rest comes off the balance, which never held them.
654 let (extras, transaction) = self.extras_of(charge["payment_intent"].as_str(), charge["invoice"].as_str()).await?;
655 let paid = charge["amount"].as_i64().unwrap_or(refunded);
656 let (balance_cents, tax_cents, fee_cents) = crate::tax::refund_split(refunded, paid, extras.tax_cents, extras.fee_cents);
657 let (given_tax, given_fee) = self.refunded_extras(charge_id).await?;
658 let new = balance_cents * 10_000 - already;
659 let new_extras = crate::tax::Extras { tax_cents: -(tax_cents - given_tax), fee_cents: -(fee_cents - given_fee) };
660 if new <= 0 && new_extras == crate::tax::Extras::default() {
Mission control: a place to ask g1t, shown and not on yet, with the actions under it661 return Ok("ignored: refund already recorded".to_owned());
662 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person663 let reference = format!("refund/{charge_id}/{refunded}");
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.664 if new > 0 && plan_invoice {
665 // The plan's revenue is less; nothing was ever on the balance.
666 self.db
667 .prepare(
668 "INSERT INTO plan_payments (invoice_id, workspace, amount_micros, paid_at) VALUES (?, ?, ?, ?)
669 ON CONFLICT (invoice_id) DO NOTHING",
670 )
671 .bind(&[reference.as_str().into(), workspace.as_str().into(), ((-new) as f64).into(), rfc3339(now_ms()).into()])?
672 .run()
673 .await?;
674 } else 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.675 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 person676 }
677 self.record_extras(&workspace, &reference, charge["payment_intent"].as_str(), new_extras, None).await?;
678 // An off-session charge's tax was recorded by g1t (auto-reload):
679 // reverse what this refund gave back. Checkout's and invoices' tax
680 // Stripe Tax keeps itself.
681 if let (Some(stripe), Some(transaction)) = (&self.stripe, transaction.as_deref()) {
682 let given_back = new.max(0) / 10_000 - new_extras.tax_cents - new_extras.fee_cents;
683 if given_back > 0
684 && let Err(error) = stripe.reverse_tax(transaction, &reference, given_back).await
685 {
686 worker::console_error!("{workspace}: the tax on refund {reference} was not reversed: {error}");
687 }
688 }
689 Ok(format!("refund of {} recorded for {workspace}", crate::features::dollars(new.max(0))))
690 }
691
692 /// What earlier refunds of a charge gave back of its tax and card fee,
693 /// in cents, as positive amounts.
694 async fn refunded_extras(&self, charge_id: &str) -> Result<(i64, i64)> {
695 #[derive(Deserialize)]
696 struct Row {
697 kind: String,
698 micros: Option<i64>,
699 }
700 let rows = self
701 .db
702 .prepare("SELECT kind, SUM(amount_micros) AS micros FROM tax_and_fees WHERE reference LIKE ? GROUP BY kind")
703 .bind(&[format!("refund/{charge_id}/%").into()])?
704 .all()
705 .await?
706 .results::<Row>()?;
707 let of = |kind: &str| rows.iter().filter(|r| r.kind == kind).map(|r| -r.micros.unwrap_or(0) / 10_000).sum::<i64>();
708 Ok((of("tax"), of("card_fee")))
Mission control: a place to ask g1t, shown and not on yet, with the actions under it709 }
710
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.711 /// Whether a Stripe invoice is a plan's, its payment recorded in
712 /// `plan_payments`.
713 async fn plan_payment_of(&self, invoice_id: &str) -> Result<bool> {
714 Ok(self
715 .db
716 .prepare("SELECT invoice_id FROM plan_payments WHERE invoice_id = ?")
717 .bind(&[invoice_id.into()])?
718 .first::<Value>(None)
719 .await?
720 .is_some())
721 }
722
723 /// A disputed payment, acted on as Stripe says it stands now (so an
724 /// event late, twice or out of order does the right thing):
725 ///
726 /// - Open: the payment never counts toward trust, and work stops.
727 /// - Won: it counts again, and the stop lifts unless another dispute is
728 /// open or what a lost one took is unpaid. Won after it was lost (the
729 /// bank reversed itself), what was taken back is given back once
730 /// (`dispute/<id>/won`, where the loss was entered) and no longer
731 /// owed.
732 /// - Lost: its credit is taken back once (`dispute/<id>` on the ledger,
733 /// or on `plan_payments` for a plan's invoice), in the share of the
734 /// payment that was not tax or card fee, and work stays stopped until
735 /// that much is paid again (`dispute_owed_micros`). A smaller payment
736 /// in the meantime is credit like any other, but does not lift it.
737 async fn disputed(&self, dispute_id: &str) -> Result<String> {
Mission control: a place to ask g1t, shown and not on yet, with the actions under it738 let Some(stripe) = &self.stripe else { return Ok("ignored".to_owned()) };
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.739 let dispute: Value = stripe.get(&format!("/disputes/{dispute_id}")).await?;
740 let state = dispute_state(dispute["status"].as_str().unwrap_or_default());
741 let Some(charge_id) = dispute["charge"].as_str().or_else(|| dispute["charge"]["id"].as_str()) else {
742 return Ok("ignored: no charge".to_owned());
743 };
Mission control: a place to ask g1t, shown and not on yet, with the actions under it744 let charge: Value = stripe.get(&format!("/charges/{charge_id}")).await?;
745 let Some(workspace) = (match charge["customer"].as_str() {
746 Some(customer) => self.workspace_of_customer(customer).await?,
747 None => None,
748 }) else {
749 return Ok("ignored: not a workspace's customer".to_owned());
750 };
751 let now = rfc3339(now_ms());
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.752 let payment_intent = charge["payment_intent"].as_str();
753 let invoice = charge["invoice"].as_str();
754 // The payment as the ledger knows it: its PaymentIntent, its
755 // invoice, or the prepayment page that took it.
756 let mut references: Vec<String> = [payment_intent, invoice].into_iter().flatten().map(str::to_owned).collect();
757 if let Some(intent) = payment_intent {
758 let pages: Value = stripe.get(&format!("/checkout/sessions?payment_intent={intent}&limit=10")).await?;
759 references.extend(pages["data"].as_array().into_iter().flatten().filter_map(|page| page["id"].as_str().map(str::to_owned)));
760 }
761 // It counts toward trust again only if won.
762 for reference in &references {
Mission control: a place to ask g1t, shown and not on yet, with the actions under it763 self.db
764 .prepare("UPDATE ledger SET disputed = ? WHERE workspace = ? AND reference = ?")
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.765 .bind(&[(if state == "won" { 0 } else { 1 }).into(), workspace.as_str().into(), reference.as_str().into()])?
Mission control: a place to ask g1t, shown and not on yet, with the actions under it766 .run()
767 .await?;
768 }
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.769 #[derive(Deserialize)]
770 struct Known {
771 status: String,
772 reversed_micros: Option<i64>,
773 }
774 let known_row = self
775 .db
776 .prepare("SELECT status, reversed_micros FROM disputes WHERE dispute_id = ?")
777 .bind(&[dispute_id.into()])?
778 .first::<Known>(None)
779 .await?;
780 let taken = known_row.as_ref().and_then(|row| row.reversed_micros).unwrap_or(0);
781 let known = known_row.map(|row| row.status);
782 // Where it stood already: an event twice, or late, changes nothing.
783 // Work is not stopped again (an owner may have paid since), and
784 // nothing is taken back or owed twice.
785 if !dispute_moves(known.as_deref(), state) {
786 return Ok(format!("ignored: dispute {dispute_id} is {state} already"));
787 }
788 let reference = format!("dispute/{dispute_id}");
789 let mut writes = vec![];
790 // A lost dispute's credit, taken back once: the reversal, the
791 // dispute's row and what is owed go in one batch, so a delivery
792 // alongside (whose reversal fails on the same reference, undoing
793 // its whole batch) never adds what is owed a second time.
794 let mut reversed = 0;
795 if state == "lost" {
796 let (extras, _) = self.extras_of(payment_intent, invoice).await?;
797 let amount = dispute["amount"].as_i64().unwrap_or(0);
798 let paid = charge["amount"].as_i64().unwrap_or(amount);
799 reversed = crate::tax::refund_split(amount, paid, extras.tax_cents, extras.fee_cents).0 * 10_000;
800 let plan = match invoice {
801 Some(invoice) => self.plan_payment_of(invoice).await?,
802 None => false,
803 };
804 if reversed > 0 && plan {
805 // No `ON CONFLICT`: a second insert fails the batch.
806 writes.push(
807 self.db
808 .prepare("INSERT INTO plan_payments (invoice_id, workspace, amount_micros, paid_at) VALUES (?, ?, ?, ?)")
809 .bind(&[reference.as_str().into(), workspace.as_str().into(), ((-reversed) as f64).into(), now.as_str().into()])?,
810 );
811 } else if reversed > 0 {
812 writes.extend(self.entry_statements(
813 &workspace,
814 EntryKind::TopUp,
815 -reversed,
816 "Taken back by the card's bank: a dispute was lost",
817 &reference,
818 None,
819 None,
820 None,
821 )?);
822 }
823 }
824 // Lost, then won: what the loss took back is given back, where it
825 // was taken (the plan's payments or the ledger), in the same batch
826 // as the dispute's row, so a delivery alongside fails on the same
827 // reference and gives nothing twice.
828 let restored = restore_on_win(known.as_deref(), state, taken);
829 if restored > 0 {
830 let back = won_reference(dispute_id);
831 if self.plan_payment_of(&reference).await? {
832 // No `ON CONFLICT`: a second insert fails the batch.
833 writes.push(
834 self.db
835 .prepare("INSERT INTO plan_payments (invoice_id, workspace, amount_micros, paid_at) VALUES (?, ?, ?, ?)")
836 .bind(&[back.as_str().into(), workspace.as_str().into(), (restored as f64).into(), now.as_str().into()])?,
837 );
838 } else {
839 writes.extend(self.entry_statements(
840 &workspace,
841 EntryKind::TopUp,
842 restored,
843 "Given back by the card's bank: a lost dispute was reversed",
844 &back,
845 None,
846 None,
847 None,
848 )?);
849 }
850 }
851 let reversing = !writes.is_empty();
852 writes.push(
Mission control: a place to ask g1t, shown and not on yet, with the actions under it853 self.db
854 .prepare(
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.855 "INSERT INTO disputes (dispute_id, workspace, charge_id, status, reversed_micros, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
856 ON CONFLICT (dispute_id) DO UPDATE SET status = ?4, reversed_micros = reversed_micros + ?5, updated_at = ?6",
Mission control: a place to ask g1t, shown and not on yet, with the actions under it857 )
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.858 .bind(&[dispute_id.into(), workspace.as_str().into(), charge_id.into(), state.into(), (reversed as f64).into(), now.as_str().into()])?,
859 );
860 if state == "won" {
861 if restored > 0 {
862 writes.push(self.db.prepare(LOWER_DISPUTE_OWED).bind(&[workspace.as_str().into(), (restored as f64).into()])?);
863 }
864 // Only the stop a dispute made: a declined card stays stopped.
865 writes.push(
866 self.db
867 .prepare(format!("{} AND autopay_error = ?2", crate::CLEAR_STOP))
868 .bind(&[workspace.as_str().into(), DISPUTE_STOP.into()])?,
869 );
Mission control: a place to ask g1t, shown and not on yet, with the actions under it870 } else {
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.871 writes.push(
872 self.db
873 .prepare(
874 "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, dispute_owed_micros, updated_at) VALUES (?1, ?2, ?3, ?4, ?2)
875 ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3,
876 dispute_owed_micros = dispute_owed_micros + ?4, updated_at = ?2",
877 )
878 .bind(&[workspace.as_str().into(), now.as_str().into(), DISPUTE_STOP.into(), (reversed as f64).into()])?,
879 );
880 }
881 match self.db.batch(writes).await {
882 Ok(_) => {}
883 // The reversal was entered already: by a delivery alongside,
884 // whose batch recorded the dispute and what is owed with it, or
885 // (before these went in one batch) on its own, its dispute
886 // left unrecorded. Only then is the dispute recorded now, at
887 // the amount entered, once.
888 // Given back already, by a delivery alongside.
889 Err(error) if restored > 0 && crate::is_duplicate(&error) => {
890 return Ok(format!("ignored: dispute {dispute_id} is won already"));
891 }
892 Err(error) if reversing && crate::is_duplicate(&error) => {
893 return self.dispute_reversed_before(&workspace, dispute_id, charge_id, &reference, &now).await;
894 }
895 Err(error) => return Err(error),
Mission control: a place to ask g1t, shown and not on yet, with the actions under it896 }
897 let account = self.account_of(&workspace).await?;
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.898 let what = match state {
899 "won" if restored > 0 => {
900 format!("dispute reversed in the workspace's favour: {} given back", crate::features::dollars(restored))
901 }
902 "won" => "dispute closed in the workspace's favour".to_owned(),
903 "lost" => format!("dispute lost: {} taken back; work stopped until it is paid", crate::features::dollars(reversed)),
904 _ => "dispute: work stopped".to_owned(),
905 };
Mission control: a place to ask g1t, shown and not on yet, with the actions under it906 self.audit(&account.id, "dispute", &format!("{workspace}: {what}"), "stripe").await?;
907 Ok(format!("{workspace}: {what}"))
908 }
909
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.910 /// A lost dispute whose reversal is on the ledger (or the plan's
911 /// payments) already. Recorded as lost, and what it took back owed,
912 /// only by the one delivery that moves the dispute's row to lost.
913 async fn dispute_reversed_before(&self, workspace: &str, dispute_id: &str, charge_id: &str, reference: &str, now: &str) -> Result<String> {
914 #[derive(Deserialize)]
915 struct Sum {
916 micros: Option<i64>,
917 }
918 let reversed = self
919 .db
920 .prepare(
921 "SELECT -(COALESCE((SELECT SUM(amount_micros) FROM ledger WHERE reference = ?1), 0)
922 + COALESCE((SELECT SUM(amount_micros) FROM plan_payments WHERE invoice_id = ?1), 0)) AS micros",
923 )
924 .bind(&[reference.into()])?
925 .first::<Sum>(None)
926 .await?
927 .and_then(|s| s.micros)
928 .unwrap_or(0)
929 .max(0);
930 let moved = self
931 .db
932 .prepare(
933 "INSERT INTO disputes (dispute_id, workspace, charge_id, status, reversed_micros, updated_at) VALUES (?1, ?2, ?3, 'lost', ?4, ?5)
934 ON CONFLICT (dispute_id) DO UPDATE SET status = 'lost', reversed_micros = ?4, updated_at = ?5 WHERE disputes.status <> 'lost'
935 RETURNING dispute_id",
936 )
937 .bind(&[dispute_id.into(), workspace.into(), charge_id.into(), (reversed as f64).into(), now.into()])?
938 .first::<Value>(None)
939 .await?
940 .is_some();
941 if !moved {
942 return Ok(format!("ignored: dispute {dispute_id} is lost already"));
943 }
944 self.db
945 .prepare(
946 "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, dispute_owed_micros, updated_at) VALUES (?1, ?2, ?3, ?4, ?2)
947 ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3,
948 dispute_owed_micros = dispute_owed_micros + ?4, updated_at = ?2",
949 )
950 .bind(&[workspace.into(), now.into(), DISPUTE_STOP.into(), (reversed as f64).into()])?
951 .run()
952 .await?;
953 Ok(format!("{workspace}: dispute lost: {} taken back earlier; work stopped until it is paid", crate::features::dollars(reversed)))
954 }
955
Mission control: a place to ask g1t, shown and not on yet, with the actions under it956 // --- Enterprise invoices ------------------------------------------------
957
958 pub(crate) async fn admin_enterprise_billing(&self, a: AdminEnterpriseBillingArgs) -> Result<Outcome<BillingAccount>> {
959 let email = a.email.trim().to_lowercase();
960 if !email.contains('@') || email.contains(char::is_whitespace) || a.by.trim().is_empty() {
961 return Ok(Outcome::fail(FailureCode::Invalid, "Give the email the enterprise's invoices go to."));
962 }
963 let Some(stripe) = &self.stripe else {
964 return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t."));
965 };
966 #[derive(Deserialize)]
967 struct Row {
968 name: String,
969 kind: String,
970 customer_id: Option<String>,
971 }
972 let Some(row) = self
973 .db
974 .prepare("SELECT name, kind, customer_id FROM billing_accounts WHERE id = ?")
975 .bind(&[a.id.as_str().into()])?
976 .first::<Row>(None)
977 .await?
978 .filter(|row| row.kind == "enterprise")
979 else {
980 return Ok(Outcome::fail(FailureCode::NotFound, "No such enterprise."));
981 };
982 #[derive(Deserialize)]
983 struct Customer {
984 id: String,
985 }
986 let fields = [
987 ("name", row.name.clone()),
988 ("email", email.clone()),
989 ("metadata[g1t_enterprise]", a.id.clone()),
990 ];
991 let customer: Customer = match &row.customer_id {
992 Some(id) => stripe.post(&format!("/customers/{id}"), &fields).await?,
993 None => stripe.post("/customers", &fields).await?,
994 };
995 self.db
996 .prepare("UPDATE billing_accounts SET billing_email = ?, customer_id = ? WHERE id = ?")
997 .bind(&[email.as_str().into(), customer.id.as_str().into(), a.id.as_str().into()])?
998 .run()
999 .await?;
1000 self.audit(&a.id, "billing_email", &format!("Invoices go to {email}"), &a.by).await?;
1001 Ok(match self.enterprise(&a.id).await? {
1002 Some(account) => Outcome::Ok(account),
1003 None => Outcome::fail(FailureCode::NotFound, "No such enterprise."),
1004 })
1005 }
1006
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1007 /// `admin_enterprise_address`: the address Stripe Tax works the
1008 /// enterprise's invoices out from, and its tax ID, on its customer.
1009 pub(crate) async fn admin_enterprise_address(&self, a: AdminEnterpriseAddressArgs) -> Result<Outcome<bool>> {
1010 if a.by.trim().is_empty() {
1011 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is changing it."));
1012 }
1013 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())) {
1014 return Ok(Outcome::fail(FailureCode::Invalid, why));
1015 }
1016 let place = serde_json::json!({ "country": a.address.country.trim(), "postal_code": a.address.postal_code.trim(), "state": a.address.state.trim() });
1017 if !crate::stripe::address_places_customer(&place) {
1018 return Ok(Outcome::fail(FailureCode::Invalid, "Give at least the country, and in the US the ZIP code: Stripe Tax needs them."));
1019 }
1020 let Some(stripe) = &self.stripe else {
1021 return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t."));
1022 };
1023 #[derive(Deserialize)]
1024 struct Row {
1025 kind: String,
1026 customer_id: Option<String>,
1027 }
1028 let Some(row) = self
1029 .db
1030 .prepare("SELECT kind, customer_id FROM billing_accounts WHERE id = ?")
1031 .bind(&[a.id.as_str().into()])?
1032 .first::<Row>(None)
1033 .await?
1034 .filter(|row| row.kind == "enterprise")
1035 else {
1036 return Ok(Outcome::fail(FailureCode::NotFound, "No such enterprise."));
1037 };
1038 let Some(customer) = row.customer_id else {
1039 return Ok(Outcome::fail(FailureCode::Conflict, "Set where its invoices go first: that makes its Stripe customer."));
1040 };
1041 let fields: Vec<(&str, String)> = vec![
1042 ("address[line1]", a.address.line1.trim().to_owned()),
1043 ("address[line2]", a.address.line2.trim().to_owned()),
1044 ("address[city]", a.address.city.trim().to_owned()),
1045 ("address[state]", a.address.state.trim().to_owned()),
1046 ("address[postal_code]", a.address.postal_code.trim().to_owned()),
1047 ("address[country]", a.address.country.trim().to_uppercase()),
1048 ];
1049 if let Err(error) = stripe.post::<Value>(&format!("/customers/{customer}"), &fields).await {
1050 return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error)));
1051 }
1052 if let (Some(kind), Some(value)) = (a.tax_id_type.as_deref(), a.tax_id.as_deref())
1053 && let Err(why) = crate::details::replace_tax_id(stripe, &customer, &a.id, kind, value).await
1054 {
1055 return Ok(Outcome::fail(FailureCode::Invalid, why));
1056 }
1057 self.audit(&a.id, "billing_address", &format!("Billing address set ({})", a.address.country.trim().to_uppercase()), &a.by).await?;
1058 Ok(Outcome::Ok(true))
1059 }
1060
Mission control: a place to ask g1t, shown and not on yet, with the actions under it1061 pub(crate) async fn admin_invoice_enterprise(&self, a: AdminInvoiceEnterpriseArgs) -> Result<Outcome<EnterpriseInvoice>> {
1062 if a.by.trim().is_empty() {
1063 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is sending it."));
1064 }
1065 match self.invoice_enterprise(&a.id, "now", &a.by).await? {
1066 Ok(invoice) => Ok(Outcome::Ok(invoice)),
1067 Err(why) => Ok(Outcome::fail(FailureCode::Conflict, why)),
1068 }
1069 }
1070
1071 /// Invoices each enterprise for the month that closed. Live payments
1072 /// only; sudo can send one sooner in test mode.
1073 pub(crate) async fn invoice_enterprises(&self) -> Result<()> {
1074 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
1075 return Ok(());
1076 }
1077 let closing = crate::limits::previous_month(&rfc3339(now_ms())[..7]);
1078 #[derive(Deserialize)]
1079 struct Id {
1080 id: String,
1081 }
1082 let due = self
1083 .db
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging1084 .prepare(format!(
Mission control: a place to ask g1t, shown and not on yet, with the actions under it1085 "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 charging1086 AND NOT {full}
Mission control: a place to ask g1t, shown and not on yet, with the actions under it1087 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 charging1088 full = crate::sales::FULL_DISCOUNT_SQL
1089 ))
Mission control: a place to ask g1t, shown and not on yet, with the actions under it1090 .bind(&[closing.as_str().into()])?
1091 .all()
1092 .await?
1093 .results::<Id>()?;
1094 for Id { id } in due {
1095 if let Err(why) = self.invoice_enterprise(&id, &closing, "month close").await? {
1096 worker::console_log!("enterprise {id} not invoiced for {closing}: {why}");
1097 }
1098 }
1099 Ok(())
1100 }
1101
1102 /// One invoice for what each of the enterprise's workspaces owes now.
1103 async fn invoice_enterprise(&self, id: &str, period: &str, by: &str) -> Result<std::result::Result<EnterpriseInvoice, String>> {
1104 let Some(stripe) = &self.stripe else { return Ok(Err("Payments are not set up.".into())) };
1105 let Some(account) = self.enterprise(id).await? else { return Ok(Err("No such enterprise.".into())) };
1106 #[derive(Deserialize)]
1107 struct Customer {
1108 customer_id: Option<String>,
1109 }
1110 let Some(customer) = self
1111 .db
1112 .prepare("SELECT customer_id FROM billing_accounts WHERE id = ?")
1113 .bind(&[id.into()])?
1114 .first::<Customer>(None)
1115 .await?
1116 .and_then(|row| row.customer_id)
1117 else {
1118 return Ok(Err("Set where the enterprise's invoices go first.".into()));
1119 };
1120 // What each workspace owes: its charges less what it has paid, and
1121 // less what is on invoices still open.
1122 let mut lines = vec![];
1123 for workspace in &account.workspaces {
1124 let balance = self.row(workspace).await?.map_or(0, |row| row.balance_micros);
1125 #[derive(Deserialize)]
1126 struct Sum {
1127 micros: Option<i64>,
1128 }
1129 let invoiced = self
1130 .db
1131 .prepare(
1132 "SELECT SUM(l.amount_micros) AS micros FROM enterprise_invoice_lines l
1133 JOIN enterprise_invoices i ON i.invoice_id = l.invoice_id
1134 WHERE l.workspace = ? AND i.status IN ('open', 'overdue')",
1135 )
1136 .bind(&[workspace.as_str().into()])?
1137 .first::<Sum>(None)
1138 .await?
1139 .and_then(|s| s.micros)
1140 .unwrap_or(0);
1141 let owed = (-balance).max(0) - invoiced;
1142 if owed >= 10_000 {
1143 lines.push(InvoiceLine { workspace: workspace.clone(), amount_micros: owed });
1144 }
1145 }
1146 if lines.is_empty() {
1147 return Ok(Err("Its workspaces owe nothing to invoice.".into()));
1148 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1149 // Stripe Tax places the customer by its address; without one the
1150 // invoice could not be finalized, so it is not started.
1151 match stripe.customer_placed(&customer).await {
1152 Ok(true) => {}
1153 Ok(false) => {
1154 return Ok(Err(format!(
1155 "Add {}'s billing address first (Invoices → Billing address): Stripe needs it to work out tax.",
1156 account.name
1157 )));
1158 }
1159 Err(error) => return Ok(Err(crate::stripe::friendly(&error))),
1160 }
1161 // Invoiced, never by card: no card fee. Tax on top, by Stripe Tax.
1162 let mut fields = vec![
Mission control: a place to ask g1t, shown and not on yet, with the actions under it1163 ("customer", customer.clone()),
1164 ("collection_method", "send_invoice".to_owned()),
1165 ("days_until_due", "30".to_owned()),
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging1166 ("pending_invoice_items_behavior", "exclude".to_owned()),
Mission control: a place to ask g1t, shown and not on yet, with the actions under it1167 ("description", format!("g1t usage for the {} enterprise", account.name)),
1168 ("metadata[g1t_enterprise]", id.to_owned()),
1169 ("metadata[period]", period.to_owned()),
1170 ];
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1171 fields.extend(crate::stripe::invoice_tax_fields());
Mission control: a place to ask g1t, shown and not on yet, with the actions under it1172 #[derive(Deserialize)]
1173 struct Invoice {
1174 id: String,
1175 #[serde(default)]
1176 hosted_invoice_url: Option<String>,
1177 #[serde(default)]
1178 amount_due: i64,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1179 /// The tax Stripe added, in cents (`tax`, in this API version).
1180 #[serde(default)]
1181 tax: Option<i64>,
Mission control: a place to ask g1t, shown and not on yet, with the actions under it1182 }
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging1183 // The draft first, keyed on what it bills, and its lines put on it:
1184 // an attempt that failed half way is found again, never billed again
1185 // by an invoice sweeping its pending lines in.
1186 let cents: Vec<i64> = lines.iter().map(|line| (line.amount_micros + 9_999) / 10_000).collect();
1187 let keyed: Vec<(&str, i64)> = lines.iter().zip(&cents).map(|(line, cents)| (line.workspace.as_str(), *cents)).collect();
1188 let key = enterprise_invoice_key(id, period, &keyed);
1189 let draft: Invoice = stripe.post_idempotent("/invoices", &fields, &key).await?;
1190 for (line, cents) in lines.iter().zip(&cents) {
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1191 let mut fields = vec![
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging1192 ("customer", customer.clone()),
1193 ("invoice", draft.id.clone()),
1194 ("amount", cents.to_string()),
1195 ("currency", "usd".to_owned()),
1196 ("description", format!("{}: g1t usage", line.workspace)),
1197 ("metadata[workspace]", line.workspace.clone()),
1198 ];
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1199 fields.extend(crate::stripe::item_tax_fields());
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging1200 let _: Value = stripe.post_idempotent("/invoiceitems", &fields, &format!("{key}/item/{}", line.workspace)).await?;
1201 }
1202 // A retry finds it finalized already; that is fine.
1203 let _ = stripe.post::<Value>(&format!("/invoices/{}/finalize", draft.id), &[]).await;
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1204 let sent: Invoice = match stripe.post(&format!("/invoices/{}/send", draft.id), &[]).await {
1205 Ok(sent) => sent,
1206 Err(error) if crate::stripe::is_tax_location_error(&error) => {
1207 return Ok(Err(format!("Stripe could not work out tax for {}: add its billing address, then send it again.", account.name)));
1208 }
1209 Err(error) => return Err(error),
1210 };
Mission control: a place to ask g1t, shown and not on yet, with the actions under it1211 let now = rfc3339(now_ms());
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1212 // What the workspaces owe, before tax: the tax is the enterprise's
1213 // to pay on top, never their usage, and is kept apart.
1214 let tax_cents = sent.tax.unwrap_or(0).max(0);
1215 let total = lines.iter().map(|l| l.amount_micros).sum::<i64>().max((sent.amount_due - tax_cents) * 10_000);
Mission control: a place to ask g1t, shown and not on yet, with the actions under it1216 let mut writes = vec![self
1217 .db
1218 .prepare(
1219 "INSERT INTO enterprise_invoices (invoice_id, account_id, period, amount_micros, status, hosted_url, created_by, created_at)
1220 VALUES (?, ?, ?, ?, 'open', ?, ?, ?)",
1221 )
1222 .bind(&[
1223 sent.id.as_str().into(),
1224 id.into(),
1225 period.into(),
1226 (total as f64).into(),
1227 crate::optional(sent.hosted_invoice_url.as_deref()),
1228 by.into(),
1229 now.as_str().into(),
1230 ])?];
1231 for line in &lines {
1232 writes.push(
1233 self.db
1234 .prepare("INSERT INTO enterprise_invoice_lines (invoice_id, workspace, amount_micros) VALUES (?, ?, ?)")
1235 .bind(&[sent.id.as_str().into(), line.workspace.as_str().into(), (line.amount_micros as f64).into()])?,
1236 );
1237 }
1238 self.db.batch(writes).await?;
1239 self.audit(id, "invoice", &format!("Invoice {} for {} sent ({period})", sent.id, crate::features::dollars(total)), by)
1240 .await?;
1241 Ok(Ok(EnterpriseInvoice {
1242 invoice_id: sent.id,
1243 hosted_url: sent.hosted_invoice_url,
1244 amount_micros: total,
1245 status: "open".to_owned(),
1246 period: period.to_owned(),
1247 lines,
1248 created_at: now,
1249 }))
1250 }
1251
1252 /// An enterprise invoice paid: each workspace is credited its line, and
1253 /// any stop for the invoice is lifted.
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.1254 ///
1255 /// Each line is credited before the invoice is marked paid, once by its
1256 /// ledger reference: a failure part way leaves it open, and the event
1257 /// again credits only the lines not yet credited.
1258 async fn enterprise_invoice_paid(&self, invoice_id: &str) -> Result<String> {
1259 let open = self
Mission control: a place to ask g1t, shown and not on yet, with the actions under it1260 .db
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.1261 .prepare("SELECT invoice_id FROM enterprise_invoices WHERE invoice_id = ? AND status <> 'paid'")
1262 .bind(&[invoice_id.into()])?
Mission control: a place to ask g1t, shown and not on yet, with the actions under it1263 .first::<Value>(None)
1264 .await?;
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.1265 if open.is_none() {
Mission control: a place to ask g1t, shown and not on yet, with the actions under it1266 return Ok("ignored: not an open enterprise invoice".to_owned());
1267 }
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.1268 // Read at billing's API version, before anything is written: the
1269 // event's copy may lack `tax` and `payment_intent`.
1270 let fetched: Option<Value> = match &self.stripe {
1271 Some(stripe) => Some(stripe.get(&format!("/invoices/{invoice_id}")).await?),
1272 None => None,
1273 };
Mission control: a place to ask g1t, shown and not on yet, with the actions under it1274 let lines = self.invoice_lines(invoice_id).await?;
1275 for line in &lines {
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.1276 let reference = format!("inv/{invoice_id}/{}", line.workspace);
1277 let seen = self.db.prepare("SELECT id FROM ledger WHERE reference = ?").bind(&[reference.as_str().into()])?.first::<Value>(None).await?;
1278 if seen.is_some() {
1279 continue;
1280 }
1281 let entered = self
1282 .enter(
1283 &line.workspace,
1284 EntryKind::TopUp,
1285 line.amount_micros,
1286 &format!("Paid on the enterprise's invoice {invoice_id}"),
1287 &reference,
1288 None,
1289 None,
1290 None,
1291 )
1292 .await;
1293 match entered {
1294 Ok(()) => {}
1295 Err(error) if crate::is_duplicate(&error) => {}
1296 Err(error) => return Err(error),
1297 }
1298 }
1299 self.db
1300 .prepare("UPDATE enterprise_invoices SET status = 'paid', paid_at = ? WHERE invoice_id = ? AND status <> 'paid'")
1301 .bind(&[rfc3339(now_ms()).into(), invoice_id.into()])?
1302 .run()
Mission control: a place to ask g1t, shown and not on yet, with the actions under it1303 .await?;
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1304 // Its tax is the enterprise's, paid on top of what its workspaces
1305 // owed, and never credited to them.
1306 #[derive(Deserialize)]
1307 struct Account {
1308 account_id: String,
1309 }
1310 if let Some(account) = self
1311 .db
1312 .prepare("SELECT account_id FROM enterprise_invoices WHERE invoice_id = ?")
1313 .bind(&[invoice_id.into()])?
1314 .first::<Account>(None)
1315 .await?
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.1316 && let Some(invoice) = &fetched
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1317 {
1318 let extras = crate::tax::Extras { tax_cents: invoice["tax"].as_i64().unwrap_or(0).max(0), fee_cents: 0 };
1319 self.record_extras(&account.account_id, invoice_id, invoice["payment_intent"].as_str(), extras, None).await?;
1320 }
Mission control: a place to ask g1t, shown and not on yet, with the actions under it1321 Ok(format!("invoice {invoice_id} paid; {} workspaces credited", lines.len()))
1322 }
1323
1324 /// An enterprise invoice that went overdue stops its workspaces' work;
1325 /// one voided is simply closed.
1326 async fn enterprise_invoice_status(&self, invoice_id: &str, status: &str) -> Result<String> {
1327 let updated = self
1328 .db
1329 .prepare("UPDATE enterprise_invoices SET status = ? WHERE invoice_id = ? AND status <> 'paid' RETURNING invoice_id")
1330 .bind(&[status.into(), invoice_id.into()])?
1331 .first::<Value>(None)
1332 .await?;
1333 if updated.is_none() {
1334 return Ok("ignored: not an open enterprise invoice".to_owned());
1335 }
1336 if status == "overdue" {
1337 let now = rfc3339(now_ms());
1338 for line in self.invoice_lines(invoice_id).await? {
1339 self.db
1340 .prepare(
1341 "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2)
1342 ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2",
1343 )
1344 .bind(&[line.workspace.as_str().into(), now.as_str().into(), format!("the enterprise's invoice {invoice_id} is overdue").into()])?
1345 .run()
1346 .await?;
1347 }
1348 }
1349 Ok(format!("invoice {invoice_id} is {status}"))
1350 }
1351
1352 async fn invoice_lines(&self, invoice_id: &str) -> Result<Vec<InvoiceLine>> {
1353 Ok(self
1354 .db
1355 .prepare("SELECT workspace, amount_micros FROM enterprise_invoice_lines WHERE invoice_id = ?")
1356 .bind(&[invoice_id.into()])?
1357 .all()
1358 .await?
1359 .results::<LineRow>()?
1360 .into_iter()
1361 .map(|row| InvoiceLine { workspace: row.workspace, amount_micros: row.amount_micros })
1362 .collect())
1363 }
1364
1365 /// An enterprise's invoices, newest first.
1366 pub(crate) async fn enterprise_invoices(&self, id: &str) -> Result<Vec<EnterpriseInvoice>> {
1367 let rows = self
1368 .db
1369 .prepare("SELECT * FROM enterprise_invoices WHERE account_id = ? ORDER BY created_at DESC LIMIT 24")
1370 .bind(&[JsValue::from(id)])?
1371 .all()
1372 .await?
1373 .results::<InvoiceRow>()?;
1374 let mut invoices = vec![];
1375 for row in rows {
1376 invoices.push(EnterpriseInvoice {
1377 lines: self.invoice_lines(&row.invoice_id).await?,
1378 invoice_id: row.invoice_id,
1379 hosted_url: row.hosted_url,
1380 amount_micros: row.amount_micros,
1381 status: row.status,
1382 period: row.period,
1383 created_at: row.created_at,
1384 });
1385 }
1386 Ok(invoices)
1387 }
1388}
1389
1390#[cfg(test)]
1391mod tests {
1392 use super::*;
1393
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging1394 #[test]
1395 fn an_enterprise_invoice_attempted_again_is_the_same_invoice() {
1396 let lines = [("acme", 1_250), ("acme-labs", 99)];
1397 let key = enterprise_invoice_key("ent_1", "2026-09", &lines);
1398 assert_eq!(key, enterprise_invoice_key("ent_1", "2026-09", &lines));
1399 assert!(key.len() < 255 && key.starts_with("ent-invoice/ent_1/2026-09/"));
1400 // Different amounts, even with the same total, are a different invoice.
1401 assert_ne!(key, enterprise_invoice_key("ent_1", "2026-09", &[("acme", 1_300), ("acme-labs", 49)]));
1402 assert_ne!(key, enterprise_invoice_key("ent_1", "2026-10", &lines));
1403 }
1404
Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API.1405 #[test]
1406 fn an_event_from_the_other_mode_is_ignored() {
1407 assert!(wrong_mode(Some(false), "live").is_some());
1408 assert!(wrong_mode(Some(true), "test").is_some());
1409 assert!(wrong_mode(Some(true), "live").is_none());
1410 assert!(wrong_mode(Some(false), "test").is_none());
1411 assert!(wrong_mode(None, "live").is_none());
1412 }
1413
1414 #[test]
1415 fn a_dispute_won_after_it_was_lost_gives_back_what_it_took_once() {
1416 // Lost then won: the $46.40 taken back is given back.
1417 assert_eq!(restore_on_win(Some("lost"), "won", 46_400_000), 46_400_000);
1418 // Won from open, or won again (a redelivery): nothing.
1419 assert_eq!(restore_on_win(Some("open"), "won", 0), 0);
1420 assert_eq!(restore_on_win(None, "won", 0), 0);
1421 assert_eq!(restore_on_win(Some("won"), "won", 46_400_000), 0);
1422 assert!(!dispute_moves(Some("won"), "won"));
1423 assert_eq!(restore_on_win(Some("lost"), "lost", 46_400_000), 0);
1424 assert_eq!(restore_on_win(Some("lost"), "won", -5), 0);
1425 // Its own reference: unique, so a delivery alongside fails the
1426 // batch instead of giving it back twice; apart from the loss's.
1427 assert_eq!(won_reference("dp_1"), "dispute/dp_1/won");
1428 assert_ne!(won_reference("dp_1"), "dispute/dp_1");
1429 // What is owed comes down by it, never below nothing, before the
1430 // stop is cleared (which needs nothing owed).
1431 assert!(LOWER_DISPUTE_OWED.contains("MAX(0, dispute_owed_micros - ?2)"));
1432 assert!(crate::CLEAR_STOP.contains("dispute_owed_micros = 0"));
1433 }
1434
1435 #[test]
1436 fn a_dispute_event_twice_or_late_changes_nothing() {
1437 assert!(dispute_moves(None, "open"));
1438 assert!(dispute_moves(None, "lost"));
1439 assert!(dispute_moves(Some("open"), "lost"));
1440 assert!(dispute_moves(Some("open"), "won"));
1441 assert!(dispute_moves(Some("lost"), "won"));
1442 // The same state again: nothing stopped, taken back or owed twice.
1443 for state in ["open", "won", "lost"] {
1444 assert!(!dispute_moves(Some(state), state), "{state}");
1445 }
1446 // A late `created` after the dispute was lost never re-opens it.
1447 assert!(!dispute_moves(Some("lost"), "open"));
1448 }
1449
1450 #[test]
1451 fn a_refund_counts_what_earlier_refunds_took_back_wherever_they_were_kept() {
1452 assert!(ALREADY_REFUNDED_SQL.contains("FROM plan_payments WHERE invoice_id LIKE ?1"));
1453 assert!(ALREADY_REFUNDED_SQL.contains("FROM ledger WHERE reference LIKE ?1"));
1454 assert_eq!(crate::rename::parameters(ALREADY_REFUNDED_SQL), 1);
1455 }
1456
1457 #[test]
1458 fn a_dispute_is_acted_on_as_it_stands_now() {
1459 for open in ["warning_needs_response", "warning_under_review", "needs_response", "under_review"] {
1460 assert_eq!(dispute_state(open), "open");
1461 }
1462 assert_eq!(dispute_state("won"), "won");
1463 assert_eq!(dispute_state("warning_closed"), "won");
1464 assert_eq!(dispute_state("lost"), "lost");
1465 // A lost dispute takes back only the credit: $50 paid as $46.40
1466 // credit, $1.60 card fee and $2 tax.
1467 assert_eq!(crate::tax::refund_split(5_000, 5_000, 200, 160).0, 4_640);
1468 // Lifting a stop for a won dispute leaves a declined card's.
1469 assert!(crate::CLEAR_STOP.contains("dispute_owed_micros = 0") && crate::CLEAR_STOP.contains("status = 'open'"));
1470 assert!(!DISPUTE_STOP.is_empty());
1471 }
1472
Mission control: a place to ask g1t, shown and not on yet, with the actions under it1473 fn sign(payload: &str, secret: &str, t: i64) -> String {
1474 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
1475 mac.update(format!("{t}.{payload}").as_bytes());
1476 format!("t={t},v1={}", hex::encode(mac.finalize().into_bytes()))
1477 }
1478
1479 #[test]
1480 fn a_destination_misses_the_events_billing_handles_that_it_does_not_send() {
1481 let has: Vec<String> = EVENTS.iter().take(11).map(|e| (*e).to_owned()).collect();
1482 assert_eq!(missing_events(&has), EVENTS[11..].iter().map(|e| (*e).to_owned()).collect::<Vec<_>>());
1483 let all: Vec<String> = EVENTS.iter().map(|e| (*e).to_owned()).collect();
1484 assert!(missing_events(&all).is_empty());
1485 assert!(missing_events(&["*".to_owned()]).is_empty());
1486 }
1487
1488 #[test]
1489 fn a_signed_event_is_believed_only_as_signed_and_only_fresh() {
1490 let payload = r#"{"id":"evt_1","type":"invoice.paid"}"#;
1491 let header = sign(payload, "whsec_test", 1_000_000);
1492 assert!(verify(payload, &header, "whsec_test", 1_000_010));
1493 assert!(!verify(payload, &header, "whsec_other", 1_000_010));
1494 assert!(!verify(&payload.replace("paid", "voided"), &header, "whsec_test", 1_000_010));
1495 assert!(!verify(payload, &header, "whsec_test", 1_000_000 + 301));
1496 assert!(!verify(payload, "v1=abc", "whsec_test", 1_000_000));
1497 // Stripe may sign with more than one secret while one is rolled.
1498 let both = format!("{},v1=00ff", sign(payload, "whsec_test", 1_000_000));
1499 assert!(verify(payload, &both, "whsec_test", 1_000_000));
1500 }
1501}

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