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 it | 1 | //! Stripe telling billing what happened, and enterprise invoices. |
| 2 | //! | |
| 3 | //! Most of billing asks Stripe when it needs to know: a payment page is | |
| 4 | //! confirmed when the person comes back, a plan is checked when its period | |
| 5 | //! ends. That misses whatever happens while no one is looking: a page paid | |
| 6 | //! for and closed, a renewal that failed, a refund, a dispute, an invoice | |
| 7 | //! paid by bank transfer a week later. Stripe sends each as an event to | |
| 8 | //! `https://api.g1t.sh/stripe/webhook`; the API passes the raw body and its | |
| 9 | //! signature here, untouched. | |
| 10 | //! | |
| 11 | //! - The endpoint is registered by billing itself, from sudo, once per | |
| 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 it | 17 | //! - 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 | ||
| 27 | use g1t_contracts::billing::{ | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 28 | 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 it | 30 | }; |
| 31 | use g1t_contracts::time::rfc3339; | |
| 32 | use g1t_contracts::{FailureCode, Outcome}; | |
| 33 | use g1t_kit::now_ms; | |
| 34 | use hmac::{Hmac, Mac}; | |
| 35 | use serde::Deserialize; | |
| 36 | use serde_json::Value; | |
| 37 | use sha2::Sha256; | |
| 38 | use worker::Result; | |
| 39 | use worker::wasm_bindgen::JsValue; | |
| 40 | ||
| 41 | use crate::Billing; | |
| 42 | ||
| 43 | /// Where Stripe sends events. | |
| 44 | pub(crate) const WEBHOOK_URL: &str = "https://api.g1t.sh/stripe/webhook"; | |
| 45 | ||
| 46 | /// The events billing acts on. | |
| 47 | pub(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 audit | 70 | /// 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). | |
| 73 | pub(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. | |
| 80 | pub(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. | |
| 88 | pub(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. | |
| 92 | const 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`. | |
| 96 | pub(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. | |
| 109 | pub(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. | |
| 122 | pub(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. | |
| 128 | pub(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. | |
| 134 | pub(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 it | 137 | /// Events billing handles that `has` does not include, in billing's order. |
| 138 | pub(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. | |
| 147 | const TOLERANCE_SECONDS: i64 = 5 * 60; | |
| 148 | ||
| 149 | /// Whether `header` (`t=…,v1=…`) signs `payload` with `secret`, within the | |
| 150 | /// tolerance of `now_seconds`. | |
| 151 | pub(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)] | |
| 178 | pub(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)] | |
| 188 | struct EventRow { | |
| 189 | id: String, | |
| 190 | r#type: String, | |
| 191 | outcome: String, | |
| 192 | received_at: String, | |
| 193 | } | |
| 194 | ||
| 195 | #[derive(Deserialize)] | |
| 196 | struct 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)] | |
| 206 | struct LineRow { | |
| 207 | workspace: String, | |
| 208 | amount_micros: i64, | |
| 209 | } | |
| 210 | ||
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 211 | /// The idempotency key of an enterprise's invoice: the same enterprise, |
| 212 | /// period and amounts are the same invoice however often it is attempted. | |
| 213 | pub(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 it | 224 | impl 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 it | 301 | 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 it | 310 | }) |
| 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 it | 370 | 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 audit | 385 | 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 it | 387 | 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 it | 392 | } |
| 393 | } | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 394 | "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 it | 395 | (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 it | 397 | _ => "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 it | 413 | "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 person | 433 | #[serde(default)] |
| 434 | fee_cents: Option<u32>, | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 435 | } |
| 436 | let Some(open) = self | |
| 437 | .db | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 438 | .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 it | 439 | .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 audit | 456 | } |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 457 | 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 it | 466 | match open.feature.as_deref() { |
| 467 | None => { | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 468 | 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 it | 473 | 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 it | 492 | 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 person | 499 | /// 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 person | 512 | 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 it | 515 | return Ok(()); |
| 516 | } | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 517 | #[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 it | 531 | 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 it | 582 | 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 it | 603 | #[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 it | 624 | 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 it | 640 | #[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 it | 647 | .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 person | 652 | // 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 it | 661 | return Ok("ignored: refund already recorded".to_owned()); |
| 662 | } | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 663 | 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 person | 676 | } |
| 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 it | 709 | } |
| 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 it | 738 | 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 it | 744 | 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 it | 763 | 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 it | 766 | .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 it | 853 | 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 it | 857 | ) |
| 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 it | 870 | } 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 it | 896 | } |
| 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 it | 906 | 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 it | 956 | // --- 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 person | 1007 | /// `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 it | 1061 | 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 charging | 1084 | .prepare(format!( |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 1085 | "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 charging | 1086 | AND NOT {full} |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 1087 | 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 charging | 1088 | full = crate::sales::FULL_DISCOUNT_SQL |
| 1089 | )) | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 1090 | .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 person | 1149 | // 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 it | 1163 | ("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 charging | 1166 | ("pending_invoice_items_behavior", "exclude".to_owned()), |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 1167 | ("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 person | 1171 | fields.extend(crate::stripe::invoice_tax_fields()); |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 1172 | #[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 person | 1179 | /// 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 it | 1182 | } |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 1183 | // 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(¢s).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(¢s) { | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 1191 | let mut fields = vec![ |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 1192 | ("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 person | 1199 | fields.extend(crate::stripe::item_tax_fields()); |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 1200 | 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 person | 1204 | 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 it | 1211 | let now = rfc3339(now_ms()); |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 1212 | // 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 it | 1216 | 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 it | 1260 | .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 it | 1263 | .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 it | 1266 | 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 it | 1274 | 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 it | 1303 | .await?; |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 1304 | // 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 person | 1317 | { |
| 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 it | 1321 | 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)] | |
| 1391 | mod tests { | |
| 1392 | use super::*; | |
| 1393 | ||
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 1394 | #[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 it | 1473 | 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.