// SPDX-License-Identifier: AGPL-3.0-or-later use crate::{ api::{ client::ApiError, types::{ AppBrandingConfigUpdateRequest, AppPublicConfigUpdateRequest, BILLING_MAX_COUNTRY_CURRENCIES, BILLING_MAX_CURRENCIES, BILLING_MAX_LEGACY_PRICES_PER_SLOT, BILLING_MAX_LEGACY_SLOTS, BILLING_PRICE_SLOTS, BillingPriceSet, InstanceBillingUpdateRequest, InstanceConfigUpdateRequest, PREMIUM_PRODUCT_NAME_MAX_CHARS, TRI_STATE_DEFAULT, TRI_STATE_OFF, TRI_STATE_ON, }, }, middleware::flash::FlashData, utils::forms::MultiValueForm, }; use std::collections::BTreeMap; const PRICE_ID_MAX_CHARS: usize = 255; const INFO_URL_MAX_CHARS: usize = 2048; pub(super) fn build_billing_update( form: &MultiValueForm, ) -> Result { let premium_product_name = if form.contains_key("billing_premium_product_name") { Some(parse_premium_product_name( form.clean("billing_premium_product_name"), )?) } else { None }; let premium_info_url = if form.contains_key("billing_premium_info_url") { Some(parse_info_url(form.clean("billing_premium_info_url"))?) } else { None }; let branding = (premium_product_name.is_some() || premium_info_url.is_some()).then(|| { AppBrandingConfigUpdateRequest { premium_product_name, premium_info_url, ..Default::default() } }); let prices = if form.contains_key("billing_price_currency") { Some(parse_price_rows(form)?) } else { None }; let default_currency = if form.contains_key("billing_default_currency") { Some( form.clean("billing_default_currency") .map(|value| parse_currency(&value)) .transpose()?, ) } else { None }; let country_currencies = if form.contains_key("billing_country_currencies") { Some(parse_country_currencies( form.first("billing_country_currencies").unwrap_or(""), )?) } else { None }; let legacy_prices = if form.contains_key("billing_legacy_prices") { Some(parse_legacy_prices( form.first("billing_legacy_prices").unwrap_or(""), )?) } else { None }; if let Some(Some(prices)) = &prices { if let Some(Some(currency)) = &default_currency && !prices.contains_key(currency) { return Err(format!( "Default currency {currency} has no row in the price table" )); } if let Some(Some(countries)) = &country_currencies && let Some((country, currency)) = countries .iter() .find(|(_, currency)| !prices.contains_key(*currency)) { return Err(format!( "{country} maps to {currency}, which has no row in the price table" )); } } Ok(InstanceConfigUpdateRequest { app_public: branding.map(|branding| AppPublicConfigUpdateRequest { branding: Some(branding), ..Default::default() }), billing: Some(InstanceBillingUpdateRequest { enabled: parse_tri_state(form, "billing_enabled")?, stripe_secret_key: secret_update( form, "billing_stripe_secret_key", "billing_clear_stripe_secret_key", ), stripe_webhook_secret: secret_update( form, "billing_stripe_webhook_secret", "billing_clear_stripe_webhook_secret", ), default_currency, prices, country_currencies, legacy_prices, automatic_tax: parse_tri_state(form, "billing_automatic_tax")?, tax_id_collection: parse_tri_state(form, "billing_tax_id_collection")?, terms_consent_required: parse_tri_state(form, "billing_terms_consent_required")?, }), ..Default::default() }) } fn parse_tri_state(form: &MultiValueForm, key: &str) -> Result>, String> { if !form.contains_key(key) { return Ok(None); } match form.first(key).map(str::trim).unwrap_or("") { TRI_STATE_DEFAULT => Ok(Some(None)), TRI_STATE_ON => Ok(Some(Some(true))), TRI_STATE_OFF => Ok(Some(Some(false))), other => Err(format!("Invalid choice \"{other}\" for {key}")), } } pub(super) fn billing_result(result: Result) -> FlashData { match result { Ok(_) => FlashData::success("Premium and billing settings updated"), Err(error) => { tracing::warn!(%error, "admin API request failed: update billing config"); match validation_message(&error) { Some(message) => FlashData::error(format!( "Failed to update premium and billing settings: {message}" )), None => FlashData::error("Failed to update premium and billing settings"), } } } } pub(super) fn validation_message(error: &ApiError) -> Option { let ApiError::Http { status: 400, message, } = error else { return None; }; let body: serde_json::Value = serde_json::from_str(message).ok()?; let first = body["errors"].as_array().and_then(|errors| errors.first()); let detail = first.and_then(|error| { let message = error["message"].as_str()?; Some( match error["path"].as_str().filter(|path| !path.is_empty()) { Some(path) => format!("{path}: {message}"), None => message.to_owned(), }, ) }); detail.or_else(|| body["message"].as_str().map(str::to_owned)) } fn secret_update(form: &MultiValueForm, key: &str, clear_key: &str) -> Option> { match form.clean(key) { Some(secret) => Some(Some(secret)), None if form.bool_value(clear_key) => Some(None), None => None, } } fn parse_premium_product_name(value: Option) -> Result, String> { match value { Some(name) if name.encode_utf16().count() > PREMIUM_PRODUCT_NAME_MAX_CHARS => Err(format!( "Premium name must be at most {PREMIUM_PRODUCT_NAME_MAX_CHARS} characters" )), other => Ok(other), } } fn parse_info_url(value: Option) -> Result, String> { let Some(value) = value else { return Ok(None); }; let valid = value.chars().count() <= INFO_URL_MAX_CHARS && url::Url::parse(&value).is_ok_and(|url| { matches!(url.scheme(), "http" | "https") && url.host_str().is_some_and(|h| !h.is_empty()) }); if valid { Ok(Some(value)) } else { Err("Premium info URL must be an absolute http or https URL".to_owned()) } } fn parse_currency(value: &str) -> Result { let currency = value.trim().to_ascii_uppercase(); if currency.len() == 3 && currency.bytes().all(|byte| byte.is_ascii_uppercase()) { Ok(currency) } else { Err(format!( "Invalid currency \"{}\": use a 3-letter ISO 4217 code such as GBP", value.trim() )) } } fn parse_country(value: &str) -> Result { let country = value.trim().to_ascii_uppercase(); if country.len() == 2 && country.bytes().all(|byte| byte.is_ascii_uppercase()) { Ok(country) } else { Err(format!( "Invalid country \"{}\": use a 2-letter ISO 3166 code such as SE", value.trim() )) } } fn parse_price_id(value: &str) -> Result { let id = value.trim(); let valid = id.len() <= PRICE_ID_MAX_CHARS && id.strip_prefix("price_").is_some_and(|rest| { !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_alphanumeric()) }); if valid { Ok(id.to_owned()) } else { Err(format!( "Invalid Stripe price ID \"{id}\": it must look like price_1AbC" )) } } fn parse_optional_price_id(value: Option<&String>) -> Result, String> { match value .map(|value| value.trim()) .filter(|value| !value.is_empty()) { Some(id) => parse_price_id(id).map(Some), None => Ok(None), } } fn parse_price_rows( form: &MultiValueForm, ) -> Result>, String> { let currencies = form.values("billing_price_currency"); let column = |key: &str, index: usize| form.values(key).get(index); let mut prices = BTreeMap::new(); for (index, currency) in currencies.iter().enumerate() { if currency.trim().is_empty() { continue; } let currency = parse_currency(currency)?; let set = BillingPriceSet { monthly: parse_optional_price_id(column("billing_price_monthly", index))?, yearly: parse_optional_price_id(column("billing_price_yearly", index))?, gift_1_month: parse_optional_price_id(column("billing_price_gift_1_month", index))?, gift_1_year: parse_optional_price_id(column("billing_price_gift_1_year", index))?, }; if set.is_empty() { return Err(format!("{currency} needs at least one price ID")); } if prices.insert(currency.clone(), set).is_some() { return Err(format!( "{currency} appears more than once in the price table" )); } } if prices.len() > BILLING_MAX_CURRENCIES { return Err(format!( "The price table holds at most {BILLING_MAX_CURRENCIES} currencies" )); } Ok((!prices.is_empty()).then_some(prices)) } fn key_value_lines(value: &str) -> impl Iterator> { value .lines() .map(str::trim) .filter(|line| !line.is_empty()) .map(|line| { line.split_once('=') .map(|(key, value)| (key.trim(), value.trim())) .ok_or_else(|| format!("Line \"{line}\" must use the form KEY=VALUE")) }) } fn parse_country_currencies(value: &str) -> Result>, String> { let mut countries = BTreeMap::new(); for line in key_value_lines(value) { let (country, currency) = line?; let country = parse_country(country)?; let currency = parse_currency(currency)?; if countries.insert(country.clone(), currency).is_some() { return Err(format!("{country} is mapped more than once")); } } if countries.len() > BILLING_MAX_COUNTRY_CURRENCIES { return Err(format!( "At most {BILLING_MAX_COUNTRY_CURRENCIES} country mappings are allowed" )); } Ok((!countries.is_empty()).then_some(countries)) } fn parse_legacy_slot(value: &str) -> Result { let invalid = || { format!( "Invalid legacy price slot \"{value}\": use monthly, yearly, gift_1_month or gift_1_year followed by _ and a currency, such as monthly_GBP" ) }; let (slot, currency) = value.rsplit_once('_').ok_or_else(invalid)?; let slot = slot.to_ascii_lowercase(); if !BILLING_PRICE_SLOTS.contains(&slot.as_str()) { return Err(invalid()); } let currency = parse_currency(currency).map_err(|_| invalid())?; Ok(format!("{slot}_{currency}")) } fn parse_legacy_prices(value: &str) -> Result>>, String> { let mut legacy: BTreeMap> = BTreeMap::new(); for line in key_value_lines(value) { let (slot, ids) = line?; let slot = parse_legacy_slot(slot)?; let entry = legacy.entry(slot.clone()).or_default(); for id in ids.split(',').map(str::trim).filter(|id| !id.is_empty()) { let id = parse_price_id(id)?; if !entry.contains(&id) { entry.push(id); } } if entry.is_empty() { return Err(format!("{slot} needs at least one price ID")); } if entry.len() > BILLING_MAX_LEGACY_PRICES_PER_SLOT { return Err(format!( "{slot} holds at most {BILLING_MAX_LEGACY_PRICES_PER_SLOT} legacy price IDs" )); } } if legacy.len() > BILLING_MAX_LEGACY_SLOTS { return Err(format!( "At most {BILLING_MAX_LEGACY_SLOTS} legacy price slots are allowed" )); } Ok((!legacy.is_empty()).then_some(legacy)) } #[cfg(test)] mod tests { use super::*; use crate::api::generated::types as generated_types; use serde_json::json; fn full_form(extra: &str) -> MultiValueForm { let base = "billing_premium_product_name=%20Gold%20\ &billing_premium_info_url=https%3A%2F%2Fexample.com%2Fgold\ &billing_enabled=on\ &billing_automatic_tax=default&billing_tax_id_collection=on&billing_terms_consent_required=off\ &billing_stripe_secret_key=\ &billing_stripe_webhook_secret=whsec_new\ &billing_default_currency=gbp\ &billing_price_currency=gbp&billing_price_monthly=price_1GbpM&billing_price_yearly=price_1GbpY\ &billing_price_gift_1_month=&billing_price_gift_1_year=price_1GbpG\ &billing_price_currency=SEK&billing_price_monthly=price_1SekM&billing_price_yearly=price_1SekY\ &billing_price_gift_1_month=&billing_price_gift_1_year=\ &billing_price_currency=&billing_price_monthly=&billing_price_yearly=\ &billing_price_gift_1_month=&billing_price_gift_1_year=\ &billing_country_currencies=se%3Dsek%0D%0AGB%20%3D%20GBP%0D%0A\ &billing_legacy_prices=monthly_GBP%3Dprice_1OldA%0Amonthly_gbp%3Dprice_1OldB%2Cprice_1OldA%0Ayearly_SEK%3Dprice_1OldC"; MultiValueForm::parse(format!("{base}{extra}").as_bytes()) } #[test] fn full_billing_form_builds_the_expected_patch() { let update = build_billing_update(&full_form("")).expect("valid form"); let value = serde_json::to_value(&update).expect("serializable"); serde_json::from_value::(value.clone()) .expect("generated update contract"); assert_eq!( value, json!({ "app_public": { "branding": { "premium_product_name": "Gold", "premium_info_url": "https://example.com/gold" } }, "billing": { "enabled": true, "stripe_webhook_secret": "whsec_new", "default_currency": "GBP", "prices": { "GBP": { "monthly": "price_1GbpM", "yearly": "price_1GbpY", "gift_1_month": null, "gift_1_year": "price_1GbpG" }, "SEK": { "monthly": "price_1SekM", "yearly": "price_1SekY", "gift_1_month": null, "gift_1_year": null } }, "country_currencies": {"GB": "GBP", "SE": "SEK"}, "legacy_prices": { "monthly_GBP": ["price_1OldA", "price_1OldB"], "yearly_SEK": ["price_1OldC"] }, "automatic_tax": null, "tax_id_collection": true, "terms_consent_required": false } }) ); } #[test] fn blank_fields_clear_and_the_default_choice_sends_null() { let form = MultiValueForm::parse( b"billing_premium_product_name=&billing_premium_info_url=&billing_enabled=default\ &billing_stripe_secret_key=&billing_clear_stripe_secret_key=true\ &billing_stripe_webhook_secret=\ &billing_default_currency=\ &billing_price_currency=&billing_price_monthly=price_1Ignored\ &billing_country_currencies=&billing_legacy_prices=", ); let value = serde_json::to_value(build_billing_update(&form).expect("valid form")).unwrap(); assert_eq!( value, json!({ "app_public": { "branding": {"premium_product_name": null, "premium_info_url": null} }, "billing": { "enabled": null, "stripe_secret_key": null, "default_currency": null, "prices": null, "country_currencies": null, "legacy_prices": null } }) ); } #[test] fn a_new_secret_wins_over_the_clear_checkbox() { let form = MultiValueForm::parse( b"billing_stripe_secret_key=%20sk_live_x%20&billing_clear_stripe_secret_key=true", ); let billing = build_billing_update(&form) .expect("valid form") .billing .expect("billing"); assert_eq!( billing.stripe_secret_key, Some(Some("sk_live_x".to_owned())) ); assert_eq!(billing.stripe_webhook_secret, None); } #[test] fn absent_form_keys_leave_their_fields_untouched() { let update = build_billing_update(&MultiValueForm::parse(b"billing_enabled=off")) .expect("valid form"); assert!(update.app_public.is_none()); assert_eq!( serde_json::to_value(update.billing).unwrap(), json!({"enabled": false}) ); let untouched = build_billing_update(&MultiValueForm::parse(b"")).expect("valid form"); assert_eq!(serde_json::to_value(untouched.billing).unwrap(), json!({})); } #[test] fn invalid_input_is_rejected_with_a_message() { let cases: &[(&str, &str)] = &[ ( "billing_premium_info_url=ftp%3A%2F%2Fexample.com", "http or https", ), ("billing_premium_info_url=example.com", "http or https"), ("billing_default_currency=GB", "Invalid currency"), ("billing_enabled=true", "Invalid choice"), ("billing_automatic_tax=maybe", "Invalid choice"), ( "billing_price_currency=GBPX&billing_price_monthly=price_1A", "Invalid currency", ), ( "billing_price_currency=GBP&billing_price_monthly=prod_1A", "Invalid Stripe price ID", ), ( "billing_price_currency=GBP&billing_price_monthly=price_1-A", "Invalid Stripe price ID", ), ("billing_price_currency=GBP", "needs at least one price ID"), ( "billing_price_currency=GBP&billing_price_monthly=price_1A&billing_price_currency=gbp&billing_price_monthly=price_1B", "more than once", ), ("billing_country_currencies=SWE%3DSEK", "Invalid country"), ("billing_country_currencies=SE", "KEY=VALUE"), ( "billing_country_currencies=SE%3DSEK%0ASE%3DEUR", "mapped more than once", ), ( "billing_legacy_prices=weekly_GBP%3Dprice_1A", "Invalid legacy price slot", ), ( "billing_legacy_prices=monthly_GBP%3D", "needs at least one price ID", ), ( "billing_default_currency=EUR&billing_price_currency=GBP&billing_price_monthly=price_1A", "Default currency EUR has no row", ), ( "billing_country_currencies=SE%3DSEK&billing_price_currency=GBP&billing_price_monthly=price_1A", "SE maps to SEK", ), ]; let long_name = format!("billing_premium_product_name={}", "A".repeat(41)); let emoji_name = format!( "billing_premium_product_name=Gold{}", "%F0%9F%92%8E".repeat(20) ); let long_case = [ (long_name.as_str(), "at most 40"), (emoji_name.as_str(), "at most 40"), ]; for (body, expected) in long_case.iter().chain(cases.iter()) { let error = build_billing_update(&MultiValueForm::parse(body.as_bytes())).expect_err(body); assert!(error.contains(expected), "{body}: {error}"); } } #[test] fn premium_name_limit_counts_utf16_units() { let name = format!("{}{}", "A".repeat(39), "\u{1F48E}"); assert_eq!(name.chars().count(), 40); assert!(parse_premium_product_name(Some(name)).is_err()); let fits = format!("{}{}", "A".repeat(38), "\u{E9}\u{E9}"); assert_eq!( parse_premium_product_name(Some(fits.clone())), Ok(Some(fits)) ); } #[test] fn country_currencies_are_not_cross_checked_without_a_price_table() { let form = MultiValueForm::parse(b"billing_country_currencies=SE%3DSEK"); let billing = build_billing_update(&form).unwrap().billing.unwrap(); assert_eq!( billing.country_currencies, Some(Some(BTreeMap::from([("SE".to_owned(), "SEK".to_owned())]))) ); } #[test] fn validation_errors_surface_the_first_api_message() { let error = ApiError::Http { status: 400, message: json!({ "code": "VALIDATION_ERROR", "message": "Validation failed", "errors": [{"path": "billing.enabled", "code": "X", "message": "Switch the premium model to mirror first"}] }) .to_string(), }; assert_eq!( validation_message(&error).as_deref(), Some("billing.enabled: Switch the premium model to mirror first") ); let server_error = ApiError::Http { status: 500, message: "{}".to_owned(), }; assert_eq!(validation_message(&server_error), None); } }