Skip to content

g1t/services/billing/src/details.rs

349 lines17,209 bytesCodeBlame

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 audit1//! 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
7use futures_util::future::try_join3;
8use g1t_contracts::billing::{
9 AccountArgs, BillingDetails, Feature, PaymentMethod, PostalAddress, SetBillingDetailsArgs, StripeInvoice, UpcomingInvoice,
10};
11use g1t_contracts::time::rfc3339;
12use g1t_contracts::{FailureCode, Outcome, Role};
13use g1t_kit::now_ms;
14use serde_json::Value;
15use sha2::{Digest, Sha256};
16use worker::Result;
17
18use crate::{Billing, members_only};
19
20/// The languages Stripe writes invoices in, as `preferred_locales` takes
21/// them.
22pub(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.
29pub(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.
72pub(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.
113pub(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.
130pub(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
154impl 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)]
293mod 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}