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.
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 1 | //! Billing details: who a workspace's invoices are for, kept on its Stripe |
| 2 | //! customer and edited from the Billing page (never a card form of g1t's | |
| 3 | //! own: cards are added on Stripe's billing page), with the default | |
| 4 | //! payment method, the invoices Stripe holds, and the next invoice as | |
| 5 | //! g1t's ledger has it. | |
| 6 | ||
| 7 | use futures_util::future::try_join3; | |
| 8 | use g1t_contracts::billing::{ | |
| 9 | AccountArgs, BillingDetails, Feature, PaymentMethod, PostalAddress, SetBillingDetailsArgs, StripeInvoice, UpcomingInvoice, | |
| 10 | }; | |
| 11 | use g1t_contracts::time::rfc3339; | |
| 12 | use g1t_contracts::{FailureCode, Outcome, Role}; | |
| 13 | use g1t_kit::now_ms; | |
| 14 | use serde_json::Value; | |
| 15 | use sha2::{Digest, Sha256}; | |
| 16 | use worker::Result; | |
| 17 | ||
| 18 | use crate::{Billing, members_only}; | |
| 19 | ||
| 20 | /// The languages Stripe writes invoices in, as `preferred_locales` takes | |
| 21 | /// them. | |
| 22 | pub(crate) const LANGUAGES: [&str; 44] = [ | |
| 23 | "bg", "cs", "da", "de", "el", "en", "en-GB", "es", "es-419", "et", "fi", "fil", "fr", "fr-CA", "hr", "hu", "id", "it", "ja", | |
| 24 | "ko", "lt", "lv", "ms", "mt", "nb", "nl", "pl", "pt", "pt-BR", "ro", "ru", "sk", "sl", "sv", "th", "tr", "vi", "zh", | |
| 25 | "zh-HK", "zh-TW", "is", "hi", "he", "ar", | |
| 26 | ]; | |
| 27 | ||
| 28 | /// What is wrong with details as given, if anything. | |
| 29 | pub(crate) fn details_invalid(a: &SetBillingDetailsArgs) -> Option<&'static str> { | |
| 30 | if let Some(email) = a.email.as_deref().map(str::trim).filter(|e| !e.is_empty()) | |
| 31 | && (email.len() > 254 || !email.contains('@') || email.contains(char::is_whitespace) || email.starts_with('@') || email.ends_with('@')) | |
| 32 | { | |
| 33 | return Some("That is not an email address."); | |
| 34 | } | |
| 35 | if a.name.as_deref().is_some_and(|n| n.trim().chars().count() > 200) { | |
| 36 | return Some("Keep the company name under 200 characters."); | |
| 37 | } | |
| 38 | if let Some(address) = &a.address { | |
| 39 | if !address.country.trim().is_empty() && (address.country.trim().len() != 2 || !address.country.trim().chars().all(|c| c.is_ascii_alphabetic())) { | |
| 40 | return Some("The country is two letters, such as US or DE."); | |
| 41 | } | |
| 42 | let parts = [&address.line1, &address.line2, &address.city, &address.state, &address.postal_code]; | |
| 43 | if parts.iter().any(|p| p.chars().count() > 200) { | |
| 44 | return Some("Keep each line of the address under 200 characters."); | |
| 45 | } | |
| 46 | } | |
| 47 | if a.po_number.as_deref().is_some_and(|p| p.trim().chars().count() > 140) { | |
| 48 | return Some("Keep the purchase order under 140 characters."); | |
| 49 | } | |
| 50 | if let Some(language) = a.language.as_deref().map(str::trim).filter(|l| !l.is_empty()) | |
| 51 | && !LANGUAGES.contains(&language) | |
| 52 | { | |
| 53 | return Some("Stripe does not write invoices in that language."); | |
| 54 | } | |
| 55 | match (a.tax_id_type.as_deref().map(str::trim), a.tax_id.as_deref().map(str::trim)) { | |
| 56 | (Some(kind), Some(value)) if !kind.is_empty() && !value.is_empty() => { | |
| 57 | if kind.len() > 20 || !kind.chars().all(|c| c.is_ascii_lowercase() || c == '_') { | |
| 58 | return Some("Choose the kind of tax ID from the list."); | |
| 59 | } | |
| 60 | if value.chars().count() > 60 { | |
| 61 | return Some("That tax ID is too long."); | |
| 62 | } | |
| 63 | } | |
| 64 | (Some(kind), Some(value)) if kind.is_empty() != value.is_empty() => return Some("Give the tax ID's kind and its number together."), | |
| 65 | _ => {} | |
| 66 | } | |
| 67 | None | |
| 68 | } | |
| 69 | ||
| 70 | /// The customer's fields to send for the details given: absent ones left | |
| 71 | /// as they are, empty ones cleared. | |
| 72 | pub(crate) fn customer_fields(a: &SetBillingDetailsArgs) -> Vec<(&'static str, String)> { | |
| 73 | let mut fields = vec![]; | |
| 74 | if let Some(email) = &a.email { | |
| 75 | fields.push(("email", email.trim().to_owned())); | |
| 76 | } | |
| 77 | if let Some(name) = &a.name { | |
| 78 | fields.push(("name", name.trim().to_owned())); | |
| 79 | } | |
| 80 | if let Some(address) = &a.address { | |
| 81 | fields.extend([ | |
| 82 | ("address[line1]", address.line1.trim().to_owned()), | |
| 83 | ("address[line2]", address.line2.trim().to_owned()), | |
| 84 | ("address[city]", address.city.trim().to_owned()), | |
| 85 | ("address[state]", address.state.trim().to_owned()), | |
| 86 | ("address[postal_code]", address.postal_code.trim().to_owned()), | |
| 87 | ("address[country]", address.country.trim().to_uppercase()), | |
| 88 | ]); | |
| 89 | } | |
| 90 | if let Some(po) = &a.po_number { | |
| 91 | let po = po.trim(); | |
| 92 | fields.push(("metadata[po_number]", po.to_owned())); | |
| 93 | // Printed on every invoice; empty clears it. | |
| 94 | if po.is_empty() { | |
| 95 | fields.push(("invoice_settings[custom_fields]", String::new())); | |
| 96 | } else { | |
| 97 | fields.push(("invoice_settings[custom_fields][0][name]", "Purchase order".to_owned())); | |
| 98 | fields.push(("invoice_settings[custom_fields][0][value]", po.to_owned())); | |
| 99 | } | |
| 100 | } | |
| 101 | if let Some(language) = &a.language { | |
| 102 | let language = language.trim(); | |
| 103 | if language.is_empty() { | |
| 104 | fields.push(("preferred_locales", String::new())); | |
| 105 | } else { | |
| 106 | fields.push(("preferred_locales[0]", language.to_owned())); | |
| 107 | } | |
| 108 | } | |
| 109 | fields | |
| 110 | } | |
| 111 | ||
| 112 | /// An invoice as Stripe answers it. | |
| 113 | pub(crate) fn invoice_from(v: &Value) -> Option<StripeInvoice> { | |
| 114 | Some(StripeInvoice { | |
| 115 | id: v["id"].as_str()?.to_owned(), | |
| 116 | number: v["number"].as_str().map(str::to_owned), | |
| 117 | status: v["status"].as_str().unwrap_or("draft").to_owned(), | |
| 118 | total_cents: v["total"].as_i64().unwrap_or(0), | |
| 119 | currency: v["currency"].as_str().unwrap_or("usd").to_owned(), | |
| 120 | created_at: rfc3339(v["created"].as_u64().unwrap_or(0) * 1000), | |
| 121 | description: v["description"].as_str().map(str::to_owned).or_else(|| { | |
| 122 | v["lines"]["data"].as_array().and_then(|lines| lines.first()).and_then(|line| line["description"].as_str()).map(str::to_owned) | |
| 123 | }), | |
| 124 | hosted_url: v["hosted_invoice_url"].as_str().map(str::to_owned), | |
| 125 | pdf_url: v["invoice_pdf"].as_str().map(str::to_owned), | |
| 126 | }) | |
| 127 | } | |
| 128 | ||
| 129 | /// The details Stripe keeps on a customer. | |
| 130 | pub(crate) fn details_from(customer: &Value) -> BillingDetails { | |
| 131 | let text = |v: &Value| v.as_str().map(str::to_owned).filter(|s| !s.is_empty()); | |
| 132 | let address = &customer["address"]; | |
| 133 | let tax = customer["tax_ids"]["data"].as_array().and_then(|ids| ids.first()); | |
| 134 | BillingDetails { | |
| 135 | customer: true, | |
| 136 | email: text(&customer["email"]), | |
| 137 | name: text(&customer["name"]), | |
| 138 | address: address.is_object().then(|| PostalAddress { | |
| 139 | line1: address["line1"].as_str().unwrap_or_default().to_owned(), | |
| 140 | line2: address["line2"].as_str().unwrap_or_default().to_owned(), | |
| 141 | city: address["city"].as_str().unwrap_or_default().to_owned(), | |
| 142 | state: address["state"].as_str().unwrap_or_default().to_owned(), | |
| 143 | postal_code: address["postal_code"].as_str().unwrap_or_default().to_owned(), | |
| 144 | country: address["country"].as_str().unwrap_or_default().to_owned(), | |
| 145 | }), | |
| 146 | tax_id_type: tax.and_then(|t| text(&t["type"])), | |
| 147 | tax_id: tax.and_then(|t| text(&t["value"])), | |
| 148 | po_number: text(&customer["metadata"]["po_number"]), | |
| 149 | language: customer["preferred_locales"].as_array().and_then(|l| l.first()).and_then(text), | |
| 150 | ..BillingDetails::default() | |
| 151 | } | |
| 152 | } | |
| 153 | ||
| 154 | impl Billing { | |
| 155 | /// `billing_details`: members only. | |
| 156 | pub(crate) async fn billing_details(&self, a: AccountArgs) -> Result<Outcome<BillingDetails>> { | |
| 157 | let workspace = a.workspace.to_lowercase(); | |
| 158 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 159 | return Ok(members_only()); | |
| 160 | } | |
| 161 | Ok(Outcome::Ok(self.details_of(&workspace).await?)) | |
| 162 | } | |
| 163 | ||
| 164 | async fn details_of(&self, workspace: &str) -> Result<BillingDetails> { | |
| 165 | let row = self.row(workspace).await?; | |
| 166 | let upcoming = self.upcoming(workspace, row.as_ref().map_or(0, |r| r.balance_micros)).await?; | |
| 167 | let (Some(stripe), Some(customer)) = (&self.stripe, row.and_then(|r| r.customer_id)) else { | |
| 168 | return Ok(BillingDetails { upcoming, ..BillingDetails::default() }); | |
| 169 | }; | |
| 170 | match try_join3(stripe.customer(&customer), stripe.default_payment_method(&customer), stripe.invoices(&customer)).await { | |
| 171 | Ok((found, method, invoices)) => { | |
| 172 | let mut details = details_from(&found); | |
| 173 | details.payment_method = method.map(|m| PaymentMethod { | |
| 174 | kind: m.kind, | |
| 175 | brand: m.brand, | |
| 176 | last4: m.last4, | |
| 177 | exp_month: m.exp_month, | |
| 178 | exp_year: m.exp_year, | |
| 179 | }); | |
| 180 | details.invoices = invoices.iter().filter_map(invoice_from).collect(); | |
| 181 | details.upcoming = upcoming; | |
| 182 | Ok(details) | |
| 183 | } | |
| 184 | Err(error) => { | |
| 185 | worker::console_error!("{workspace}: Stripe's customer could not be read: {error}"); | |
| 186 | // The card as last synced, at least. | |
| 187 | let card = self.saved_card(workspace).await?; | |
| 188 | Ok(BillingDetails { | |
| 189 | customer: true, | |
| 190 | payment_method: card.map(|c| PaymentMethod { | |
| 191 | kind: "card".into(), | |
| 192 | brand: Some(c.brand), | |
| 193 | last4: Some(c.last4), | |
| 194 | exp_month: Some(c.exp_month), | |
| 195 | exp_year: Some(c.exp_year), | |
| 196 | }), | |
| 197 | upcoming, | |
| 198 | unavailable: Some(crate::stripe::friendly(&error)), | |
| 199 | ..BillingDetails::default() | |
| 200 | }) | |
| 201 | } | |
| 202 | } | |
| 203 | } | |
| 204 | ||
| 205 | /// The next invoice, from the ledger: the plan and activations at their | |
| 206 | /// monthly price, and usage still owed. | |
| 207 | async fn upcoming(&self, workspace: &str, balance: i64) -> Result<UpcomingInvoice> { | |
| 208 | let month = rfc3339(now_ms())[..7].to_owned(); | |
| 209 | let mut subscriptions = 0i64; | |
| 210 | for feature in [Feature::Plan, Feature::Security] { | |
| 211 | if self.plan_on(workspace, feature).await? { | |
| 212 | subscriptions += i64::from(self.plan(feature).await?.monthly_cents) * 10_000; | |
| 213 | } | |
| 214 | } | |
| 215 | let terms = self.terms_of(workspace).await?; | |
| 216 | if terms.full_discount() { | |
| 217 | subscriptions = 0; | |
| 218 | } | |
| 219 | #[derive(serde::Deserialize)] | |
| 220 | struct Pending { | |
| 221 | cost: Option<f64>, | |
| 222 | } | |
| 223 | let pending = self | |
| 224 | .db | |
| 225 | .prepare("SELECT SUM(cost_micros) AS cost FROM pending_usage WHERE workspace = ? AND month = ? AND charged_at IS NULL") | |
| 226 | .bind(&[workspace.into(), month.as_str().into()])? | |
| 227 | .first::<Pending>(None) | |
| 228 | .await? | |
| 229 | .and_then(|p| p.cost) | |
| 230 | .unwrap_or(0.0) as i64; | |
| 231 | let pending = terms.apply(crate::credits::with_margin(pending, self.margin_percent)); | |
| 232 | let usage = self.owed_with(workspace, balance).await? + pending.max(0); | |
| 233 | Ok(UpcomingInvoice { | |
| 234 | closes_at: crate::credits::next_month_start(&month), | |
| 235 | subscriptions_micros: subscriptions, | |
| 236 | usage_micros: usage, | |
| 237 | total_micros: subscriptions + usage, | |
| 238 | }) | |
| 239 | } | |
| 240 | ||
| 241 | /// `set_billing_details`: owners only, saved on the Stripe customer. | |
| 242 | pub(crate) async fn set_billing_details(&self, a: SetBillingDetailsArgs) -> Result<Outcome<BillingDetails>> { | |
| 243 | let workspace = a.workspace.to_lowercase(); | |
| 244 | if a.actor.role_in(&workspace) != Some(Role::Owner) { | |
| 245 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can change the workspace's billing details.")); | |
| 246 | } | |
| 247 | let Some(stripe) = &self.stripe else { | |
| 248 | return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t.")); | |
| 249 | }; | |
| 250 | if let Some(why) = details_invalid(&a) { | |
| 251 | return Ok(Outcome::fail(FailureCode::Invalid, why)); | |
| 252 | } | |
| 253 | let customer = match self.customer_for(&workspace).await { | |
| 254 | Ok(customer) => customer, | |
| 255 | Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))), | |
| 256 | }; | |
| 257 | let fields = customer_fields(&a); | |
| 258 | if !fields.is_empty() { | |
| 259 | let key = format!("details/{workspace}/{}", hex::encode(Sha256::digest(crate::stripe::form(&fields).as_bytes()))); | |
| 260 | let saved: Result<Value> = stripe.post_idempotent(&format!("/customers/{customer}"), &fields, &key).await; | |
| 261 | if let Err(error) = saved { | |
| 262 | return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))); | |
| 263 | } | |
| 264 | } | |
| 265 | // A tax ID replaces the one there was. | |
| 266 | if let (Some(kind), Some(value)) = (a.tax_id_type.as_deref().map(str::trim), a.tax_id.as_deref().map(str::trim)) { | |
| 267 | let existing: Result<Value> = stripe.get(&format!("/customers/{customer}/tax_ids?limit=10")).await; | |
| 268 | let existing = existing.ok().and_then(|list| list["data"].as_array().cloned()).unwrap_or_default(); | |
| 269 | let same = existing.iter().any(|t| t["type"].as_str() == Some(kind) && t["value"].as_str() == Some(value)); | |
| 270 | if !same { | |
| 271 | if !value.is_empty() { | |
| 272 | let key = format!("tax_id/{workspace}/{kind}/{value}"); | |
| 273 | let added: Result<Value> = | |
| 274 | stripe.post_idempotent(&format!("/customers/{customer}/tax_ids"), &[("type", kind.to_owned()), ("value", value.to_owned())], &key).await; | |
| 275 | if let Err(error) = added { | |
| 276 | return Ok(Outcome::fail(FailureCode::Invalid, crate::stripe::friendly(&error))); | |
| 277 | } | |
| 278 | } | |
| 279 | for old in existing { | |
| 280 | if let Some(id) = old["id"].as_str() { | |
| 281 | let _: Result<Value> = stripe.delete(&format!("/customers/{customer}/tax_ids/{id}")).await; | |
| 282 | } | |
| 283 | } | |
| 284 | } | |
| 285 | } | |
| 286 | let account = self.account_of(&workspace).await?; | |
| 287 | self.audit(&account.id, "billing_details", &format!("{workspace}: invoice details changed"), &a.actor.username).await?; | |
| 288 | Ok(Outcome::Ok(self.details_of(&workspace).await?)) | |
| 289 | } | |
| 290 | } | |
| 291 | ||
| 292 | #[cfg(test)] | |
| 293 | mod tests { | |
| 294 | use super::*; | |
| 295 | use serde_json::json; | |
| 296 | ||
| 297 | fn args() -> SetBillingDetailsArgs { | |
| 298 | SetBillingDetailsArgs { | |
| 299 | actor: serde_json::from_value(json!({ "id": "usr_1", "username": "ada" })).unwrap(), | |
| 300 | workspace: "acme".into(), | |
| 301 | email: None, | |
| 302 | name: None, | |
| 303 | address: None, | |
| 304 | tax_id_type: None, | |
| 305 | tax_id: None, | |
| 306 | po_number: None, | |
| 307 | language: None, | |
| 308 | } | |
| 309 | } | |
| 310 | ||
| 311 | #[test] | |
| 312 | fn details_are_checked_before_stripe_sees_them() { | |
| 313 | assert_eq!(details_invalid(&args()), None); | |
| 314 | assert!(details_invalid(&SetBillingDetailsArgs { email: Some("not an email".into()), ..args() }).is_some()); | |
| 315 | assert!(details_invalid(&SetBillingDetailsArgs { language: Some("klingon".into()), ..args() }).is_some()); | |
| 316 | assert!(details_invalid(&SetBillingDetailsArgs { tax_id_type: Some("eu_vat".into()), tax_id: Some(String::new()), ..args() }).is_some()); | |
| 317 | assert_eq!(details_invalid(&SetBillingDetailsArgs { tax_id_type: Some("eu_vat".into()), tax_id: Some("DE123456789".into()), ..args() }), None); | |
| 318 | let address = PostalAddress { country: "Germany".into(), ..PostalAddress::default() }; | |
| 319 | assert!(details_invalid(&SetBillingDetailsArgs { address: Some(address), ..args() }).is_some()); | |
| 320 | } | |
| 321 | ||
| 322 | #[test] | |
| 323 | fn only_what_was_given_is_sent_and_empty_clears() { | |
| 324 | assert!(customer_fields(&args()).is_empty()); | |
| 325 | let fields = customer_fields(&SetBillingDetailsArgs { po_number: Some("PO-7".into()), language: Some("fr".into()), ..args() }); | |
| 326 | assert!(fields.contains(&("invoice_settings[custom_fields][0][value]", "PO-7".to_owned()))); | |
| 327 | assert!(fields.contains(&("preferred_locales[0]", "fr".to_owned()))); | |
| 328 | let cleared = customer_fields(&SetBillingDetailsArgs { po_number: Some(" ".into()), ..args() }); | |
| 329 | assert!(cleared.contains(&("invoice_settings[custom_fields]", String::new()))); | |
| 330 | } | |
| 331 | ||
| 332 | #[test] | |
| 333 | fn stripe_answers_read_as_details_and_invoices() { | |
| 334 | let customer = json!({ | |
| 335 | "email": "billing@acme.test", "name": "Acme, Inc.", | |
| 336 | "address": { "line1": "1 Main St", "city": "Springfield", "country": "US", "postal_code": "12345" }, | |
| 337 | "tax_ids": { "data": [{ "type": "us_ein", "value": "12-3456789" }] }, | |
| 338 | "metadata": { "po_number": "PO-7" }, "preferred_locales": ["en"], | |
| 339 | }); | |
| 340 | let details = details_from(&customer); | |
| 341 | assert_eq!(details.name.as_deref(), Some("Acme, Inc.")); | |
| 342 | assert_eq!(details.address.unwrap().city, "Springfield"); | |
| 343 | assert_eq!(details.tax_id_type.as_deref(), Some("us_ein")); | |
| 344 | assert_eq!(details.po_number.as_deref(), Some("PO-7")); | |
| 345 | let invoice = invoice_from(&json!({ "id": "in_1", "status": "paid", "total": 2000, "currency": "usd", "created": 1791000000, "invoice_pdf": "https://pay.stripe.com/x.pdf" })).unwrap(); | |
| 346 | assert_eq!((invoice.total_cents, invoice.pdf_url.as_deref()), (2000, Some("https://pay.stripe.com/x.pdf"))); | |
| 347 | assert!(invoice_from(&json!({})).is_none()); | |
| 348 | } | |
| 349 | } |