Billing: ask Stripe for tax only where Stripe Tax is active
Stripe refuses a payment page, invoice or subscription with automatic_tax when Stripe Tax is not active for the key's mode, which is the case on the test account g1t.sh still uses (Tax is on in live mode). The client now reads GET /tax/settings at most every ten minutes and, when it is not active, leaves automatic_tax out of those requests and skips the tax calculation on auto-reload (nothing to record). When the settings cannot be read it asks for tax anyway, so a live account never undercharges silently. send_raw is the request itself; send adds the check.
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 |