| 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}; |
| 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(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. |
| 56 | pub(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. |
| 86 | pub(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. |
| 99 | pub(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. |
| 117 | pub(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. |
| 158 | pub(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. |
| 175 | pub(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. |
| 205 | pub(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 | |
| 217 | impl 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)] |
| 378 | mod 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 | } |