Skip to content

g1t/services/billing/src/details.rs

404 lines20,052 bytesCodeBlame
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, 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(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.
219 pub(crate) async fn billing_details(&self, a: AccountArgs) -> Result<Outcome<BillingDetails>> {
220 let workspace = a.workspace.to_lowercase();
221 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
222 return Ok(members_only());
223 }
224 Ok(Outcome::Ok(self.details_of(&workspace).await?))
225 }
226
227 async fn details_of(&self, workspace: &str) -> Result<BillingDetails> {
228 let row = self.row(workspace).await?;
229 let upcoming = self.upcoming(workspace, row.as_ref().map_or(0, |r| r.balance_micros)).await?;
230 let (Some(stripe), Some(customer)) = (&self.stripe, row.and_then(|r| r.customer_id)) else {
231 return Ok(BillingDetails { upcoming, ..BillingDetails::default() });
232 };
233 match try_join3(stripe.customer(&customer), stripe.default_payment_method(&customer), stripe.invoices(&customer)).await {
234 Ok((found, method, invoices)) => {
235 let mut details = details_from(&found);
236 details.payment_method = method.map(|m| PaymentMethod {
237 kind: m.kind,
238 brand: m.brand,
239 last4: m.last4,
240 exp_month: m.exp_month,
241 exp_year: m.exp_year,
242 });
243 details.invoices = invoices.iter().filter_map(invoice_from).collect();
244 details.upcoming = upcoming;
245 details.tax_address_needed_at = self.tax_address_needed_at(workspace).await?;
246 Ok(details)
247 }
248 Err(error) => {
249 worker::console_error!("{workspace}: Stripe's customer could not be read: {error}");
250 // The card as last synced, at least.
251 let card = self.saved_card(workspace).await?;
252 Ok(BillingDetails {
253 customer: true,
254 payment_method: card.map(|c| PaymentMethod {
255 kind: "card".into(),
256 brand: Some(c.brand),
257 last4: Some(c.last4),
258 exp_month: Some(c.exp_month),
259 exp_year: Some(c.exp_year),
260 }),
261 upcoming,
262 unavailable: Some(crate::stripe::friendly(&error)),
263 ..BillingDetails::default()
264 })
265 }
266 }
267 }
268
269 /// The next invoice, from the ledger: the plan and activations at their
270 /// monthly price, and usage still owed.
271 async fn upcoming(&self, workspace: &str, balance: i64) -> Result<UpcomingInvoice> {
272 let month = rfc3339(now_ms())[..7].to_owned();
273 let mut subscriptions = 0i64;
274 for feature in [Feature::Plan, Feature::Security] {
275 if self.plan_on(workspace, feature).await? {
276 let plan = self.plan(feature).await?;
277 subscriptions += i64::from(plan.monthly_cents + plan.card_fee_cents) * 10_000;
278 }
279 }
280 let terms = self.terms_of(workspace).await?;
281 if terms.full_discount() {
282 subscriptions = 0;
283 }
284 #[derive(serde::Deserialize)]
285 struct Pending {
286 cost: Option<f64>,
287 }
288 let pending = self
289 .db
290 .prepare("SELECT SUM(cost_micros) AS cost FROM pending_usage WHERE workspace = ? AND month = ? AND charged_at IS NULL")
291 .bind(&[workspace.into(), month.as_str().into()])?
292 .first::<Pending>(None)
293 .await?
294 .and_then(|p| p.cost)
295 .unwrap_or(0.0) as i64;
296 let pending = terms.apply(crate::credits::with_margin(pending, self.margin_percent));
297 let usage = self.owed_with(workspace, balance).await? + pending.max(0);
298 Ok(UpcomingInvoice {
299 closes_at: crate::credits::next_month_start(&month),
300 subscriptions_micros: subscriptions,
301 usage_micros: usage,
302 total_micros: subscriptions + usage,
303 })
304 }
305
306 /// `set_billing_details`: owners only, saved on the Stripe customer.
307 pub(crate) async fn set_billing_details(&self, a: SetBillingDetailsArgs) -> Result<Outcome<BillingDetails>> {
308 let workspace = a.workspace.to_lowercase();
309 if a.actor.role_in(&workspace) != Some(Role::Owner) {
310 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can change the workspace's billing details."));
311 }
312 let Some(stripe) = &self.stripe else {
313 return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t."));
314 };
315 if let Some(why) = details_invalid(&a) {
316 return Ok(Outcome::fail(FailureCode::Invalid, why));
317 }
318 let customer = match self.customer_for(&workspace).await {
319 Ok(customer) => customer,
320 Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))),
321 };
322 let fields = customer_fields(&a);
323 if !fields.is_empty() {
324 let key = format!("details/{workspace}/{}", hex::encode(Sha256::digest(crate::stripe::form(&fields).as_bytes())));
325 let saved: Result<Value> = stripe.post_idempotent(&format!("/customers/{customer}"), &fields, &key).await;
326 if let Err(error) = saved {
327 return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error)));
328 }
329 }
330 // A tax ID replaces the one there was.
331 if let (Some(kind), Some(value)) = (a.tax_id_type.as_deref(), a.tax_id.as_deref())
332 && let Err(why) = replace_tax_id(stripe, &customer, &workspace, kind, value).await
333 {
334 return Ok(Outcome::fail(FailureCode::Invalid, why));
335 }
336 let account = self.account_of(&workspace).await?;
337 self.audit(&account.id, "billing_details", &format!("{workspace}: invoice details changed"), &a.actor.username).await?;
338 let details = self.details_of(&workspace).await?;
339 // An address Stripe Tax can use lifts the hold on charging.
340 if details.tax_location {
341 self.tax_address_given(&workspace).await?;
342 }
343 Ok(Outcome::Ok(BillingDetails { tax_address_needed_at: None, ..details }))
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use serde_json::json;
351
352 fn args() -> SetBillingDetailsArgs {
353 SetBillingDetailsArgs {
354 actor: serde_json::from_value(json!({ "id": "usr_1", "username": "ada" })).unwrap(),
355 workspace: "acme".into(),
356 email: None,
357 name: None,
358 address: None,
359 tax_id_type: None,
360 tax_id: None,
361 po_number: None,
362 language: None,
363 }
364 }
365
366 #[test]
367 fn details_are_checked_before_stripe_sees_them() {
368 assert_eq!(details_invalid(&args()), None);
369 assert!(details_invalid(&SetBillingDetailsArgs { email: Some("not an email".into()), ..args() }).is_some());
370 assert!(details_invalid(&SetBillingDetailsArgs { language: Some("klingon".into()), ..args() }).is_some());
371 assert!(details_invalid(&SetBillingDetailsArgs { tax_id_type: Some("eu_vat".into()), tax_id: Some(String::new()), ..args() }).is_some());
372 assert_eq!(details_invalid(&SetBillingDetailsArgs { tax_id_type: Some("eu_vat".into()), tax_id: Some("DE123456789".into()), ..args() }), None);
373 let address = PostalAddress { country: "Germany".into(), ..PostalAddress::default() };
374 assert!(details_invalid(&SetBillingDetailsArgs { address: Some(address), ..args() }).is_some());
375 }
376
377 #[test]
378 fn only_what_was_given_is_sent_and_empty_clears() {
379 assert!(customer_fields(&args()).is_empty());
380 let fields = customer_fields(&SetBillingDetailsArgs { po_number: Some("PO-7".into()), language: Some("fr".into()), ..args() });
381 assert!(fields.contains(&("invoice_settings[custom_fields][0][value]", "PO-7".to_owned())));
382 assert!(fields.contains(&("preferred_locales[0]", "fr".to_owned())));
383 let cleared = customer_fields(&SetBillingDetailsArgs { po_number: Some(" ".into()), ..args() });
384 assert!(cleared.contains(&("invoice_settings[custom_fields]", String::new())));
385 }
386
387 #[test]
388 fn stripe_answers_read_as_details_and_invoices() {
389 let customer = json!({
390 "email": "billing@acme.test", "name": "Acme, Inc.",
391 "address": { "line1": "1 Main St", "city": "Springfield", "country": "US", "postal_code": "12345" },
392 "tax_ids": { "data": [{ "type": "us_ein", "value": "12-3456789" }] },
393 "metadata": { "po_number": "PO-7" }, "preferred_locales": ["en"],
394 });
395 let details = details_from(&customer);
396 assert_eq!(details.name.as_deref(), Some("Acme, Inc."));
397 assert_eq!(details.address.unwrap().city, "Springfield");
398 assert_eq!(details.tax_id_type.as_deref(), Some("us_ein"));
399 assert_eq!(details.po_number.as_deref(), Some("PO-7"));
400 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();
401 assert_eq!((invoice.total_cents, invoice.pdf_url.as_deref()), (2000, Some("https://pay.stripe.com/x.pdf")));
402 assert!(invoice_from(&json!({})).is_none());
403 }
404}