Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
1 commit
2 files+88−40/2 viewed
| 759 | 759 | Err(error) => return Err(error), | |
| 760 | 760 | }; | |
| 761 | 761 | let tax_cents = u32::try_from(calculation.tax_amount_exclusive.max(0)).unwrap_or(0); | |
| 762 | − | let charge = crate::stripe::SavedCharge { tax_cents, tax_calculation: Some(&calculation.id), ..untaxed }; | |
| 762 | + | // An empty id: Stripe Tax is not active for this key, so no tax. | |
| 763 | + | let taxed = !calculation.id.is_empty(); | |
| 764 | + | let charge = crate::stripe::SavedCharge { tax_cents, tax_calculation: taxed.then_some(calculation.id.as_str()), ..untaxed }; | |
| 763 | 765 | let paid = match stripe.charge_saved(&charge).await { | |
| 764 | 766 | Ok(intent) if intent["status"].as_str() == Some("succeeded") => intent["id"].as_str().map(str::to_owned), | |
| 765 | 767 | Ok(intent) => { | |
| 782 | 784 | self.grant_purchased(workspace, &intent, amount, i64::from(fee_cents) * 10_000, "g1t", Some(&customer)).await?; | |
| 783 | 785 | // Recorded with Stripe Tax once paid, so it is reported and filed; | |
| 784 | 786 | // a failure is logged and the payment stands. | |
| 785 | − | let transaction = match stripe.record_tax(&calculation.id, &intent).await { | |
| 786 | − | Ok(id) => Some(id), | |
| 787 | − | Err(error) => { | |
| 787 | + | let transaction = match if taxed { Some(stripe.record_tax(&calculation.id, &intent).await) } else { None } { | |
| 788 | + | None => None, | |
| 789 | + | Some(Ok(id)) => Some(id), | |
| 790 | + | Some(Err(error)) => { | |
| 788 | 791 | worker::console_error!("{workspace}: the tax on auto-reload {intent} was not recorded with Stripe Tax: {error}"); | |
| 789 | 792 | None | |
| 790 | 793 | } |
| 227 | 227 | } | |
| 228 | 228 | ||
| 229 | 229 | /// `name=value` pairs as a form body. | |
| 230 | + | /// Requests that carry `automatic_tax`: payment pages, invoices and | |
| 231 | + | /// subscriptions. When Stripe Tax is not active for the key's mode (a test | |
| 232 | + | /// account where it was never turned on), Stripe refuses them outright, so | |
| 233 | + | /// the field is left out and the payment goes through untaxed. | |
| 234 | + | pub(crate) fn taxed_path(path: &str) -> bool { | |
| 235 | + | path == "/checkout/sessions" || path == "/invoices" || path == "/subscriptions" | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | /// A form body without its `automatic_tax[…]` fields. | |
| 239 | + | pub(crate) fn without_automatic_tax(body: &str) -> String { | |
| 240 | + | body.split('&').filter(|pair| !pair.starts_with("automatic_tax")).collect::<Vec<_>>().join("&") | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | /// How long whether Stripe Tax is active is believed, in milliseconds. | |
| 244 | + | const TAX_ACTIVE_FOR_MS: u64 = 10 * 60 * 1000; | |
| 245 | + | ||
| 246 | + | thread_local! { | |
| 247 | + | /// Whether Stripe Tax is active for the key, and when that was read. | |
| 248 | + | static TAX_ACTIVE: std::cell::Cell<Option<(bool, u64)>> = const { std::cell::Cell::new(None) }; | |
| 249 | + | } | |
| 250 | + | ||
| 230 | 251 | pub(crate) fn form(fields: &[(&str, String)]) -> String { | |
| 231 | 252 | fields | |
| 232 | 253 | .iter() | |
| 296 | 317 | body: Option<String>, | |
| 297 | 318 | idempotency_key: Option<&str>, | |
| 298 | 319 | ) -> Result<T> { | |
| 320 | + | let body = match body { | |
| 321 | + | Some(body) if matches!(method, Method::Post) && taxed_path(path) && body.contains("automatic_tax") && !self.tax_active().await => { | |
| 322 | + | Some(without_automatic_tax(&body)) | |
| 323 | + | } | |
| 324 | + | body => body, | |
| 325 | + | }; | |
| 326 | + | self.send_raw(method, path, body, idempotency_key).await | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | /// The request itself, as given: `send` without the Stripe Tax check. | |
| 330 | + | async fn send_raw<T: for<'a> Deserialize<'a>>( | |
| 331 | + | &self, | |
| 332 | + | method: Method, | |
| 333 | + | path: &str, | |
| 334 | + | body: Option<String>, | |
| 335 | + | idempotency_key: Option<&str>, | |
| 336 | + | ) -> Result<T> { | |
| 299 | 337 | let headers = Headers::new(); | |
| 300 | 338 | headers.set("authorization", &format!("Bearer {}", self.key))?; | |
| 301 | 339 | headers.set("stripe-version", STRIPE_VERSION)?; | |
| 322 | 360 | response.json().await | |
| 323 | 361 | } | |
| 324 | 362 | ||
| 363 | + | /// Whether Stripe Tax is active for this key's mode (`GET /tax/settings`, | |
| 364 | + | /// `status` `active`), read at most every ten minutes. When it cannot be | |
| 365 | + | /// read, it is taken as active: asking for tax and being refused says | |
| 366 | + | /// why, where leaving it out would undercharge without a word. | |
| 367 | + | pub async fn tax_active(&self) -> bool { | |
| 368 | + | let now = crate::now_ms(); | |
| 369 | + | if let Some((active, at)) = TAX_ACTIVE.with(|cell| cell.get()) | |
| 370 | + | && now.saturating_sub(at) < TAX_ACTIVE_FOR_MS | |
| 371 | + | { | |
| 372 | + | return active; | |
| 373 | + | } | |
| 374 | + | #[derive(Deserialize)] | |
| 375 | + | struct Settings { | |
| 376 | + | status: String, | |
| 377 | + | } | |
| 378 | + | let active = match self.send_raw::<Settings>(Method::Get, "/tax/settings", None, None).await { | |
| 379 | + | Ok(settings) => settings.status == "active", | |
| 380 | + | Err(error) => { | |
| 381 | + | worker::console_error!("Stripe Tax settings could not be read; asking for tax anyway: {error}"); | |
| 382 | + | true | |
| 383 | + | } | |
| 384 | + | }; | |
| 385 | + | TAX_ACTIVE.with(|cell| cell.set(Some((active, now)))); | |
| 386 | + | active | |
| 387 | + | } | |
| 388 | + | ||
| 325 | 389 | /// A customer for a workspace that has none yet. | |
| 326 | 390 | pub async fn create_customer(&self, workspace: &str) -> Result<String> { | |
| 327 | 391 | #[derive(Deserialize)] | |
| 506 | 570 | /// A customer Stripe cannot place fails with | |
| 507 | 571 | /// `customer_tax_location_invalid` (`is_tax_location_error`). | |
| 508 | 572 | pub async fn tax_calculation(&self, charge: &SavedCharge<'_>) -> Result<TaxCalculation> { | |
| 573 | + | if !self.tax_active().await { | |
| 574 | + | // No Stripe Tax for this key's mode: no tax, and nothing to record. | |
| 575 | + | return Ok(TaxCalculation { id: String::new(), tax_amount_exclusive: 0 }); | |
| 576 | + | } | |
| 509 | 577 | let key = format!("{}/tax", charge.key); | |
| 510 | 578 | self.send(Method::Post, "/tax/calculations", Some(form(&tax_calculation_fields(charge))), Some(&key)).await | |
| 511 | 579 | } | |
| 1130 | 1198 | } | |
| 1131 | 1199 | ||
| 1132 | 1200 | #[cfg(test)] | |
| 1201 | + | mod tax_mode_tests { | |
| 1202 | + | use super::*; | |
| 1203 | + | ||
| 1204 | + | #[test] | |
| 1205 | + | fn untaxed_requests_lose_only_automatic_tax() { | |
| 1206 | + | let body = form(&[("mode", "payment".to_owned()), ("automatic_tax[enabled]", "true".to_owned()), ("tax_id_collection[enabled]", "true".to_owned())]); | |
| 1207 | + | assert_eq!(without_automatic_tax(&body), form(&[("mode", "payment".to_owned()), ("tax_id_collection[enabled]", "true".to_owned())])); | |
| 1208 | + | assert!(taxed_path("/checkout/sessions") && taxed_path("/invoices") && taxed_path("/subscriptions")); | |
| 1209 | + | assert!(!taxed_path("/invoices/in_1/finalize") && !taxed_path("/payment_intents")); | |
| 1210 | + | } | |
| 1211 | + | } | |
| 1212 | + | ||
| 1213 | + | #[cfg(test)] | |
| 1133 | 1214 | mod tests { | |
| 1134 | 1215 | use super::*; | |
| 1135 | 1216 |