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 | } | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 38 | if let Some(why) = a.address.as_ref().and_then(address_invalid) { |
| 39 | return Some(why); | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 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 | } | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 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)) { | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 101 | (Some(kind), Some(value)) if !kind.is_empty() && !value.is_empty() => { |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 102 | if !TAX_ID_TYPES.contains(&kind) { |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 103 | return Some("Choose the kind of tax ID from the list."); |
| 104 | } | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 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."); | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 107 | } |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 108 | None |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 109 | } |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 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, | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 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"])), | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 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"]), | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 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 | ||
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 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 | ||
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 217 | impl 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; | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 245 | details.tax_address_needed_at = self.tax_address_needed_at(workspace).await?; |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 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? { | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 276 | let plan = self.plan(feature).await?; |
| 277 | subscriptions += i64::from(plan.monthly_cents + plan.card_fee_cents) * 10_000; | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 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. | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 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)); | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 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?; | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 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 })) | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 344 | } |
| 345 | } | |
| 346 | ||
| 347 | #[cfg(test)] | |
| 348 | mod 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.