Skip to content
434 linesCodeBlameRaw
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
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};
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(why) = a.address.as_ref().and_then(address_invalid) {
39 return Some(why);
40 }
41 if a.po_number.as_deref().is_some_and(|p| p.trim().chars().count() > 140) {
42 return Some("Keep the purchase order under 140 characters.");
43 }
44 if let Some(language) = a.language.as_deref().map(str::trim).filter(|l| !l.is_empty())
45 && !LANGUAGES.contains(&language)
46 {
47 return Some("Stripe does not write invoices in that language.");
48 }
49 tax_id_invalid(a.tax_id_type.as_deref(), a.tax_id.as_deref())
50}
51
52/// Puts a tax ID on the customer in place of the one there was; an empty
53/// value only takes the old one off. Stripe checks it (EU VAT numbers
54/// against VIES, for one) and Stripe Tax uses it, such as for a reverse
55/// charge. Why not, as a sentence, when Stripe refuses it.
56pub(crate) async fn replace_tax_id(
57 stripe: &crate::stripe::Stripe,
58 customer: &str,
59 owner: &str,
60 kind: &str,
61 value: &str,
62) -> std::result::Result<(), String> {
63 let (kind, value) = (kind.trim(), value.trim());
64 let existing: Result<Value> = stripe.get(&format!("/customers/{customer}/tax_ids?limit=10")).await;
65 let existing = existing.ok().and_then(|list| list["data"].as_array().cloned()).unwrap_or_default();
66 if existing.iter().any(|t| t["type"].as_str() == Some(kind) && t["value"].as_str() == Some(value)) {
67 return Ok(());
68 }
69 if !value.is_empty() {
70 let key = format!("tax_id/{owner}/{kind}/{value}");
71 let added: Result<Value> =
72 stripe.post_idempotent(&format!("/customers/{customer}/tax_ids"), &[("type", kind.to_owned()), ("value", value.to_owned())], &key).await;
73 if let Err(error) = added {
74 return Err(crate::stripe::friendly(&error));
75 }
76 }
77 for old in existing {
78 if let Some(id) = old["id"].as_str() {
79 let _: Result<Value> = stripe.delete(&format!("/customers/{customer}/tax_ids/{id}")).await;
80 }
81 }
82 Ok(())
83}
84
85/// What is wrong with an address, if anything.
86pub(crate) fn address_invalid(address: &PostalAddress) -> Option<&'static str> {
87 if !address.country.trim().is_empty() && (address.country.trim().len() != 2 || !address.country.trim().chars().all(|c| c.is_ascii_alphabetic())) {
88 return Some("The country is two letters, such as US or DE.");
89 }
90 let parts = [&address.line1, &address.line2, &address.city, &address.state, &address.postal_code];
91 if parts.iter().any(|p| p.chars().count() > 200) {
92 return Some("Keep each line of the address under 200 characters.");
93 }
94 None
95}
96
97/// What is wrong with a tax ID, if anything: a kind Stripe takes, and a
98/// number of letters, digits and separators.
99pub(crate) fn tax_id_invalid(kind: Option<&str>, value: Option<&str>) -> Option<&'static str> {
100 match (kind.map(str::trim), value.map(str::trim)) {
101 (Some(kind), Some(value)) if !kind.is_empty() && !value.is_empty() => {
102 if !TAX_ID_TYPES.contains(&kind) {
103 return Some("Choose the kind of tax ID from the list.");
104 }
105 if value.chars().count() > 60 || !value.chars().all(|c| c.is_ascii_alphanumeric() || " .-/".contains(c)) {
106 return Some("That is not a tax ID: letters, digits, spaces, dots, hyphens and slashes, up to 60.");
107 }
108 None
109 }
110 (Some(kind), Some(value)) if kind.is_empty() != value.is_empty() => Some("Give the tax ID's kind and its number together."),
111 _ => None,
112 }
113}
114
115/// The customer's fields to send for the details given: absent ones left
116/// as they are, empty ones cleared.
117pub(crate) fn customer_fields(a: &SetBillingDetailsArgs) -> Vec<(&'static str, String)> {
118 let mut fields = vec![];
119 if let Some(email) = &a.email {
120 fields.push(("email", email.trim().to_owned()));
121 }
122 if let Some(name) = &a.name {
123 fields.push(("name", name.trim().to_owned()));
124 }
125 if let Some(address) = &a.address {
126 fields.extend([
127 ("address[line1]", address.line1.trim().to_owned()),
128 ("address[line2]", address.line2.trim().to_owned()),
129 ("address[city]", address.city.trim().to_owned()),
130 ("address[state]", address.state.trim().to_owned()),
131 ("address[postal_code]", address.postal_code.trim().to_owned()),
132 ("address[country]", address.country.trim().to_uppercase()),
133 ]);
134 }
135 if let Some(po) = &a.po_number {
136 let po = po.trim();
137 fields.push(("metadata[po_number]", po.to_owned()));
138 // Printed on every invoice; empty clears it.
139 if po.is_empty() {
140 fields.push(("invoice_settings[custom_fields]", String::new()));
141 } else {
142 fields.push(("invoice_settings[custom_fields][0][name]", "Purchase order".to_owned()));
143 fields.push(("invoice_settings[custom_fields][0][value]", po.to_owned()));
144 }
145 }
146 if let Some(language) = &a.language {
147 let language = language.trim();
148 if language.is_empty() {
149 fields.push(("preferred_locales", String::new()));
150 } else {
151 fields.push(("preferred_locales[0]", language.to_owned()));
152 }
153 }
154 fields
155}
156
157/// An invoice as Stripe answers it.
158pub(crate) fn invoice_from(v: &Value) -> Option<StripeInvoice> {
159 Some(StripeInvoice {
160 id: v["id"].as_str()?.to_owned(),
161 number: v["number"].as_str().map(str::to_owned),
162 status: v["status"].as_str().unwrap_or("draft").to_owned(),
163 total_cents: v["total"].as_i64().unwrap_or(0),
164 currency: v["currency"].as_str().unwrap_or("usd").to_owned(),
165 created_at: rfc3339(v["created"].as_u64().unwrap_or(0) * 1000),
166 description: v["description"].as_str().map(str::to_owned).or_else(|| {
167 v["lines"]["data"].as_array().and_then(|lines| lines.first()).and_then(|line| line["description"].as_str()).map(str::to_owned)
168 }),
169 hosted_url: v["hosted_invoice_url"].as_str().map(str::to_owned),
170 pdf_url: v["invoice_pdf"].as_str().map(str::to_owned),
171 })
172}
173
174/// The details Stripe keeps on a customer.
175pub(crate) fn details_from(customer: &Value) -> BillingDetails {
176 let text = |v: &Value| v.as_str().map(str::to_owned).filter(|s| !s.is_empty());
177 let address = &customer["address"];
178 let tax = customer["tax_ids"]["data"].as_array().and_then(|ids| ids.first());
179 BillingDetails {
180 customer: true,
181 email: text(&customer["email"]),
182 name: text(&customer["name"]),
183 address: address.is_object().then(|| PostalAddress {
184 line1: address["line1"].as_str().unwrap_or_default().to_owned(),
185 line2: address["line2"].as_str().unwrap_or_default().to_owned(),
186 city: address["city"].as_str().unwrap_or_default().to_owned(),
187 state: address["state"].as_str().unwrap_or_default().to_owned(),
188 postal_code: address["postal_code"].as_str().unwrap_or_default().to_owned(),
189 country: address["country"].as_str().unwrap_or_default().to_owned(),
190 }),
191 tax_id_type: tax.and_then(|t| text(&t["type"])),
192 tax_id: tax.and_then(|t| text(&t["value"])),
193 tax_id_status: tax.and_then(|t| text(&t["verification"]["status"])),
194 tax_exempt: text(&customer["tax_exempt"]),
195 tax_location: crate::stripe::address_places_customer(address)
196 || crate::stripe::address_places_customer(&customer["shipping"]["address"]),
197 po_number: text(&customer["metadata"]["po_number"]),
198 language: customer["preferred_locales"].as_array().and_then(|l| l.first()).and_then(text),
199 ..BillingDetails::default()
200 }
201}
202
203/// The kinds of tax ID Stripe takes on a customer (`tax_ids[type]`) in
204/// the API version billing is written for.
205pub(crate) const TAX_ID_TYPES: &[&str] = &[
206 "ad_nrt", "ae_trn", "al_tin", "am_tin", "ao_tin", "ar_cuit", "au_abn", "au_arn", "ba_tin", "bb_tin", "bg_uic", "bh_vat",
207 "bo_tin", "br_cnpj", "br_cpf", "bs_tin", "by_tin", "ca_bn", "ca_gst_hst", "ca_pst_bc", "ca_pst_mb", "ca_pst_sk", "ca_qst",
208 "cd_nif", "ch_uid", "ch_vat", "cl_tin", "cn_tin", "co_nit", "cr_tin", "de_stn", "do_rcn", "ec_ruc", "eg_tin", "es_cif",
209 "eu_oss_vat", "eu_vat", "gb_vat", "ge_vat", "gn_nif", "hk_br", "hr_oib", "hu_tin", "id_npwp", "il_vat", "in_gst", "is_vat",
210 "jp_cn", "jp_rn", "jp_trn", "ke_pin", "kh_tin", "kr_brn", "kz_bin", "li_uid", "li_vat", "ma_vat", "md_vat", "me_pib",
211 "mk_vat", "mr_nif", "mx_rfc", "my_frp", "my_itn", "my_sst", "ng_tin", "no_vat", "no_voec", "np_pan", "nz_gst", "om_vat",
212 "pe_ruc", "ph_tin", "ro_tin", "rs_pib", "ru_inn", "ru_kpp", "sa_vat", "sg_gst", "sg_uen", "si_tin", "sn_ninea", "sr_fin",
213 "sv_nit", "th_vat", "tj_tin", "tr_tin", "tw_vat", "tz_vat", "ua_vat", "ug_tin", "us_ein", "uy_ruc", "uz_tin", "uz_vat",
214 "ve_rif", "vn_tin", "za_vat", "zm_tin", "zw_tin",
215];
216
217impl Billing {
218 /// `billing_details`: members only. Who invoices are made out to (the
219 /// address, tax ID, PO number and invoice email), the payment method
220 /// and the invoices are for owners and billing managers; any other
221 /// member gets the upcoming invoice from g1t's own ledger, and whether
222 /// a billing address is needed, with the rest left empty.
223 pub(crate) async fn billing_details(&self, a: AccountArgs) -> Result<Outcome<BillingDetails>> {
224 let workspace = a.workspace.to_lowercase();
225 let Some(viewer) = a.viewer.as_ref().filter(|viewer| viewer.is_member(&workspace)) else {
226 return Ok(members_only());
227 };
228 if !viewer.manages_billing(&workspace) {
229 return Ok(Outcome::Ok(self.ledger_details_of(&workspace).await?));
230 }
231 Ok(Outcome::Ok(self.details_of(&workspace).await?))
232 }
233
234 /// What any member may see of the details: nothing from Stripe's
235 /// customer, only the upcoming invoice and the tax-address notice.
236 async fn ledger_details_of(&self, workspace: &str) -> Result<BillingDetails> {
237 let row = self.row(workspace).await?;
238 let upcoming = self.upcoming(workspace, row.as_ref().map_or(0, |r| r.balance_micros)).await?;
239 Ok(BillingDetails {
240 customer: row.is_some_and(|r| r.customer_id.is_some()),
241 upcoming,
242 tax_address_needed_at: self.tax_address_needed_at(workspace).await?,
243 ..BillingDetails::default()
244 })
245 }
246
247 async fn details_of(&self, workspace: &str) -> Result<BillingDetails> {
248 let row = self.row(workspace).await?;
249 let upcoming = self.upcoming(workspace, row.as_ref().map_or(0, |r| r.balance_micros)).await?;
250 let (Some(stripe), Some(customer)) = (&self.stripe, row.and_then(|r| r.customer_id)) else {
251 return Ok(BillingDetails { upcoming, ..BillingDetails::default() });
252 };
253 match try_join3(stripe.customer(&customer), stripe.default_payment_method(&customer), stripe.invoices(&customer)).await {
254 Ok((found, method, invoices)) => {
255 let mut details = details_from(&found);
256 details.payment_method = method.map(|m| PaymentMethod {
257 kind: m.kind,
258 brand: m.brand,
259 last4: m.last4,
260 exp_month: m.exp_month,
261 exp_year: m.exp_year,
262 });
263 details.invoices = invoices.iter().filter_map(invoice_from).collect();
264 details.upcoming = upcoming;
265 details.tax_address_needed_at = self.tax_address_needed_at(workspace).await?;
266 Ok(details)
267 }
268 // Deleted at Stripe, or from another account: forgotten, so the
269 // next card saved makes a new one.
270 Err(error) if crate::stripe::is_missing(&error) => {
271 self.forget_customer(workspace).await?;
272 Ok(BillingDetails { upcoming, tax_address_needed_at: self.tax_address_needed_at(workspace).await?, ..BillingDetails::default() })
273 }
274 Err(error) => {
275 worker::console_error!("{workspace}: Stripe's customer could not be read: {error}");
276 // The card as last synced, at least.
277 let card = self.saved_card(workspace).await?;
278 Ok(BillingDetails {
279 customer: true,
280 payment_method: card.map(|c| PaymentMethod {
281 kind: "card".into(),
282 brand: Some(c.brand),
283 last4: Some(c.last4),
284 exp_month: Some(c.exp_month),
285 exp_year: Some(c.exp_year),
286 }),
287 upcoming,
288 unavailable: Some(crate::stripe::friendly(&error)),
289 ..BillingDetails::default()
290 })
291 }
292 }
293 }
294
295 /// The next invoice, from the ledger: the plan and activations at their
296 /// monthly price, and usage still owed.
297 async fn upcoming(&self, workspace: &str, balance: i64) -> Result<UpcomingInvoice> {
298 let month = rfc3339(now_ms())[..7].to_owned();
299 let mut subscriptions = 0i64;
300 for feature in [Feature::Plan, Feature::Security] {
301 if self.plan_on(workspace, feature).await? {
302 let plan = self.plan(feature).await?;
303 subscriptions += i64::from(plan.monthly_cents) * 10_000;
304 }
305 }
306 let terms = self.terms_of(workspace).await?;
307 if terms.full_discount() {
308 subscriptions = 0;
309 }
310 #[derive(serde::Deserialize)]
311 struct Pending {
312 cost: Option<f64>,
313 }
314 let pending = self
315 .db
316 // Less what was metered before a test-mode detach: never charged.
317 .prepare(format!(
318 "SELECT SUM({}) AS cost FROM pending_usage WHERE workspace = ? AND month = ? AND charged_at IS NULL",
319 crate::storage::NET_COST
320 ))
321 .bind(&[workspace.into(), month.as_str().into()])?
322 .first::<Pending>(None)
323 .await?
324 .and_then(|p| p.cost)
325 .unwrap_or(0.0) as i64;
326 let pending = terms.apply(crate::credits::with_margin(pending, self.margin_percent));
327 let usage = self.owed_with(workspace, balance).await? + pending.max(0);
328 Ok(UpcomingInvoice {
329 closes_at: crate::credits::next_month_start(&month),
330 subscriptions_micros: subscriptions,
331 usage_micros: usage,
332 total_micros: subscriptions + usage,
333 })
334 }
335
336 /// `set_billing_details`: owners only, saved on the Stripe customer.
337 pub(crate) async fn set_billing_details(&self, a: SetBillingDetailsArgs) -> Result<Outcome<BillingDetails>> {
338 let workspace = a.workspace.to_lowercase();
339 if !a.actor.manages_billing(&workspace) {
340 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner or a billing manager can change the workspace's billing details."));
341 }
342 let Some(stripe) = &self.stripe else {
343 return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t."));
344 };
345 if let Some(why) = details_invalid(&a) {
346 return Ok(Outcome::fail(FailureCode::Invalid, why));
347 }
348 let customer = match self.customer_for(&workspace).await {
349 Ok(customer) => customer,
350 Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))),
351 };
352 let fields = customer_fields(&a);
353 if !fields.is_empty() {
354 let key = format!("details/{workspace}/{}", hex::encode(Sha256::digest(crate::stripe::form(&fields).as_bytes())));
355 let saved: Result<Value> = stripe.post_idempotent(&format!("/customers/{customer}"), &fields, &key).await;
356 if let Err(error) = saved {
357 return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error)));
358 }
359 }
360 // A tax ID replaces the one there was.
361 if let (Some(kind), Some(value)) = (a.tax_id_type.as_deref(), a.tax_id.as_deref())
362 && let Err(why) = replace_tax_id(stripe, &customer, &workspace, kind, value).await
363 {
364 return Ok(Outcome::fail(FailureCode::Invalid, why));
365 }
366 let account = self.account_of(&workspace).await?;
367 self.audit(&account.id, "billing_details", &format!("{workspace}: invoice details changed"), &a.actor.username).await?;
368 let details = self.details_of(&workspace).await?;
369 // An address Stripe Tax can use lifts the hold on charging.
370 if details.tax_location {
371 self.tax_address_given(&workspace).await?;
372 }
373 Ok(Outcome::Ok(BillingDetails { tax_address_needed_at: None, ..details }))
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use serde_json::json;
381
382 fn args() -> SetBillingDetailsArgs {
383 SetBillingDetailsArgs {
384 actor: serde_json::from_value(json!({ "id": "usr_1", "username": "ada" })).unwrap(),
385 workspace: "acme".into(),
386 email: None,
387 name: None,
388 address: None,
389 tax_id_type: None,
390 tax_id: None,
391 po_number: None,
392 language: None,
393 }
394 }
395
396 #[test]
397 fn details_are_checked_before_stripe_sees_them() {
398 assert_eq!(details_invalid(&args()), None);
399 assert!(details_invalid(&SetBillingDetailsArgs { email: Some("not an email".into()), ..args() }).is_some());
400 assert!(details_invalid(&SetBillingDetailsArgs { language: Some("klingon".into()), ..args() }).is_some());
401 assert!(details_invalid(&SetBillingDetailsArgs { tax_id_type: Some("eu_vat".into()), tax_id: Some(String::new()), ..args() }).is_some());
402 assert_eq!(details_invalid(&SetBillingDetailsArgs { tax_id_type: Some("eu_vat".into()), tax_id: Some("DE123456789".into()), ..args() }), None);
403 let address = PostalAddress { country: "Germany".into(), ..PostalAddress::default() };
404 assert!(details_invalid(&SetBillingDetailsArgs { address: Some(address), ..args() }).is_some());
405 }
406
407 #[test]
408 fn only_what_was_given_is_sent_and_empty_clears() {
409 assert!(customer_fields(&args()).is_empty());
410 let fields = customer_fields(&SetBillingDetailsArgs { po_number: Some("PO-7".into()), language: Some("fr".into()), ..args() });
411 assert!(fields.contains(&("invoice_settings[custom_fields][0][value]", "PO-7".to_owned())));
412 assert!(fields.contains(&("preferred_locales[0]", "fr".to_owned())));
413 let cleared = customer_fields(&SetBillingDetailsArgs { po_number: Some(" ".into()), ..args() });
414 assert!(cleared.contains(&("invoice_settings[custom_fields]", String::new())));
415 }
416
417 #[test]
418 fn stripe_answers_read_as_details_and_invoices() {
419 let customer = json!({
420 "email": "billing@acme.test", "name": "Acme, Inc.",
421 "address": { "line1": "1 Main St", "city": "Springfield", "country": "US", "postal_code": "12345" },
422 "tax_ids": { "data": [{ "type": "us_ein", "value": "12-3456789" }] },
423 "metadata": { "po_number": "PO-7" }, "preferred_locales": ["en"],
424 });
425 let details = details_from(&customer);
426 assert_eq!(details.name.as_deref(), Some("Acme, Inc."));
427 assert_eq!(details.address.unwrap().city, "Springfield");
428 assert_eq!(details.tax_id_type.as_deref(), Some("us_ein"));
429 assert_eq!(details.po_number.as_deref(), Some("PO-7"));
430 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();
431 assert_eq!((invoice.total_cents, invoice.pdf_url.as_deref()), (2000, Some("https://pay.stripe.com/x.pdf")));
432 assert!(invoice_from(&json!({})).is_none());
433 }
434}