| 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. |
| 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.manages_billing(&workspace) { |
| 310 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner or a billing manager 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)] |
| 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 | } |