diff --git a/.env.example b/.env.example index ef765ce..5b55df0 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ LGTM_STORE_DID=did:... # space owner; holds receipt/attesta LGTM_SESSION_SECRET= # tower-sessions LGTM_STRIPE_SECRET_KEY=... LGTM_STRIPE_WEBHOOK_SECRET=... +# Opt-in Stripe automatic tax (=1 to enable). Requires a head-office address + tax +# registrations on the Stripe account, or Stripe rejects checkout. Off by default. +# LGTM_STRIPE_AUTOMATIC_TAX=1 LGTM_PRINTFUL_API_KEY=... LGTM_PRINTFUL_WEBHOOK_SECRET=... LGTM_ATPROTO_OAUTH_CLIENT_ID=... # admin + buyer oauth diff --git a/README.md b/README.md index f32f138..821bb8c 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ and pushing to `main` (see [RELEASING.md](./RELEASING.md)). | `LGTM_PRINTFUL_WEBHOOK_SECRET` | yes\* | `pfwh_…` | \*any non-empty value in fake mode | | `LGTM_CHECKOUT_SUCCESS_URL` | no | `…/checkout/success?session_id={CHECKOUT_SESSION_ID}` | override the derived Stripe success redirect | | `LGTM_CHECKOUT_CANCEL_URL` | no | `…/cart` | override the derived Stripe cancel redirect | +| `LGTM_STRIPE_AUTOMATIC_TAX` | no | `1` | `1` = enable Stripe automatic tax; requires a head-office address + tax registrations on the account (off by default) | | `LGTM_FAKE_PAYMENTS` | no | `1` | `1` = simulate payments (no external accounts) | | `PORT` | no | `8080` | listen port; PaaS platforms inject this | diff --git a/crates/integrations/src/stripe.rs b/crates/integrations/src/stripe.rs index eb75433..4b2f1ad 100644 --- a/crates/integrations/src/stripe.rs +++ b/crates/integrations/src/stripe.rs @@ -81,6 +81,69 @@ fn map_event(raw: &serde_json::Value) -> Option { } } +/// Build the `application/x-www-form-urlencoded` body for a Stripe Checkout Session +/// (bracket-array convention). `automatic_tax` is opt-in: enabling it requires a Stripe +/// head-office address + tax registrations, so it defaults off and every field here is +/// otherwise account-config-free. +fn checkout_form(order: &Order, opts: &CheckoutOptions, automatic_tax: bool) -> Vec<(String, String)> { + let currency = order.currency.as_str().to_lowercase(); + let mut form: Vec<(String, String)> = vec![ + ("mode".into(), "payment".into()), + ("success_url".into(), opts.success_url.clone()), + ("cancel_url".into(), opts.cancel_url.clone()), + ("client_reference_id".into(), order.id.to_string()), + ]; + if automatic_tax { + form.push(("automatic_tax[enabled]".into(), "true".into())); + } + for (i, item) in order.items.iter().enumerate() { + form.push(( + format!("line_items[{i}][price_data][currency]"), + currency.clone(), + )); + form.push(( + format!("line_items[{i}][price_data][unit_amount]"), + item.unit_amount.to_string(), + )); + // Stripe requires a non-empty product name. Prefer the snapshotted line name, + // fall back to the product AT-URI, then a generic label. + let name = if !item.name.is_empty() { + item.name.clone() + } else if !item.product_uri.is_empty() { + item.product_uri.clone() + } else { + "Item".to_string() + }; + form.push(( + format!("line_items[{i}][price_data][product_data][name]"), + name, + )); + form.push(( + format!("line_items[{i}][quantity]"), + item.quantity.to_string(), + )); + } + for (i, opt) in opts.shipping_options.iter().enumerate() { + form.push(( + format!("shipping_options[{i}][shipping_rate_data][type]"), + "fixed_amount".into(), + )); + form.push(( + format!("shipping_options[{i}][shipping_rate_data][display_name]"), + opt.label.clone(), + )); + form.push(( + format!("shipping_options[{i}][shipping_rate_data][fixed_amount][amount]"), + opt.amount.to_string(), + )); + form.push(( + format!("shipping_options[{i}][shipping_rate_data][fixed_amount][currency]"), + currency.clone(), + )); + } + form +} + pub struct StripePayment { http: reqwest::Client, } @@ -99,6 +162,17 @@ impl StripePayment { .ok_or_else(|| Error::Config("stripe.secret_key missing".into())) } + /// Whether to enable Stripe automatic tax on checkout. Opt-in (`config_for["stripe"] + /// ["automatic_tax"] == true`); defaults off because Stripe rejects the session unless + /// the account has a head-office address + tax registrations configured. + fn automatic_tax(ctx: &TenantCtx) -> bool { + ctx.config_for + .get("stripe") + .and_then(|c| c.get("automatic_tax")) + .and_then(|v| v.as_bool()) + .unwrap_or(false) + } + fn webhook_secret(ctx: &TenantCtx) -> Result { ctx.config_for .get("stripe") @@ -196,61 +270,7 @@ impl PaymentProvider for StripePayment { opts: CheckoutOptions, ) -> lgtm_core::Result { let key = Self::secret_key(ctx).map_err(|e| lgtm_core::Error::Config(e.to_string()))?; - let currency = order.currency.as_str().to_lowercase(); - - // application/x-www-form-urlencoded body in Stripe's bracket-array convention. - let mut form: Vec<(String, String)> = vec![ - ("mode".into(), "payment".into()), - ("success_url".into(), opts.success_url.clone()), - ("cancel_url".into(), opts.cancel_url.clone()), - ("automatic_tax[enabled]".into(), "true".into()), - ("client_reference_id".into(), order.id.to_string()), - ]; - for (i, item) in order.items.iter().enumerate() { - form.push(( - format!("line_items[{i}][price_data][currency]"), - currency.clone(), - )); - form.push(( - format!("line_items[{i}][price_data][unit_amount]"), - item.unit_amount.to_string(), - )); - // Stripe requires a non-empty product name. Prefer the snapshotted line name, - // fall back to the product AT-URI, then a generic label. - let name = if !item.name.is_empty() { - item.name.clone() - } else if !item.product_uri.is_empty() { - item.product_uri.clone() - } else { - "Item".to_string() - }; - form.push(( - format!("line_items[{i}][price_data][product_data][name]"), - name, - )); - form.push(( - format!("line_items[{i}][quantity]"), - item.quantity.to_string(), - )); - } - for (i, opt) in opts.shipping_options.iter().enumerate() { - form.push(( - format!("shipping_options[{i}][shipping_rate_data][type]"), - "fixed_amount".into(), - )); - form.push(( - format!("shipping_options[{i}][shipping_rate_data][display_name]"), - opt.label.clone(), - )); - form.push(( - format!("shipping_options[{i}][shipping_rate_data][fixed_amount][amount]"), - opt.amount.to_string(), - )); - form.push(( - format!("shipping_options[{i}][shipping_rate_data][fixed_amount][currency]"), - currency.clone(), - )); - } + let form = checkout_form(order, &opts, Self::automatic_tax(ctx)); let resp = self .http @@ -451,6 +471,36 @@ mod tests { assert!(map_event(&raw).is_none()); } + fn checkout_opts() -> CheckoutOptions { + CheckoutOptions { + success_url: "https://shop.example/checkout/success".into(), + cancel_url: "https://shop.example/cart".into(), + shipping_options: vec![], + } + } + + #[test] + fn test_checkout_form_omits_automatic_tax_by_default() { + let form = checkout_form(&test_order(), &checkout_opts(), false); + assert!( + !form.iter().any(|(k, _)| k == "automatic_tax[enabled]"), + "automatic_tax must be off by default (requires Stripe head-office address)" + ); + // redirect URLs + product name are forwarded (the v0.3.1 checkout fix) + assert!(form.iter().any(|(k, v)| k == "success_url" && v == "https://shop.example/checkout/success")); + assert!(form + .iter() + .any(|(k, v)| k == "line_items[0][price_data][product_data][name]" && v == "Test Tee")); + } + + #[test] + fn test_checkout_form_includes_automatic_tax_when_enabled() { + let form = checkout_form(&test_order(), &checkout_opts(), true); + assert!(form + .iter() + .any(|(k, v)| k == "automatic_tax[enabled]" && v == "true")); + } + #[tokio::test] async fn test_create_subscription_unsupported() { let p = StripePayment::new(); diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index 8910167..acda540 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -100,6 +100,9 @@ pub(crate) fn integration_config_from_env(config: &Config) -> HashMap