feat(premium): let self-hosted instances sell premium and gifts (#3025)

This commit is contained in:
Hampus
2026-09-28 21:21:51 +02:00
committed by GitHub
parent 192cec689a
commit 3736d94d73
159 changed files with 15802 additions and 9067 deletions
+129 -7
View File
@@ -2743,7 +2743,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Create one-use Plutonium gift codes with an explicit positive duration and return their complete redemption links. Lifetime gifts are not supported. Not available on self-hosted instances. Requires GIFT_CODES_GENERATE permission.",
"description": "Create one-use premium gift codes with an explicit positive duration and return their complete redemption links. Lifetime gifts are not supported. On self-hosted instances the premium mode must be mirror. Requires GIFT_CODES_GENERATE permission.",
"security": [{"adminApiKey": []}],
"requestBody": {
"required": true,
@@ -10639,7 +10639,9 @@
"favicon_url": {"nullable": true, "type": "string"},
"theme_color": {"nullable": true, "type": "string"},
"status_page_url": {"nullable": true, "type": "string"},
"status_page_incident_history_url": {"nullable": true, "type": "string"}
"status_page_incident_history_url": {"nullable": true, "type": "string"},
"premium_product_name": {"type": "string"},
"premium_info_url": {"nullable": true, "type": "string"}
},
"required": [
"product_name",
@@ -10650,7 +10652,9 @@
"favicon_url",
"theme_color",
"status_page_url",
"status_page_incident_history_url"
"status_page_incident_history_url",
"premium_product_name",
"premium_info_url"
],
"additionalProperties": false
},
@@ -10949,7 +10953,8 @@
},
"required": ["attachment_decay"],
"additionalProperties": false
}
},
"billing": {"$ref": "#/components/schemas/InstanceBillingResponse"}
},
"required": [
"sso",
@@ -10965,7 +10970,8 @@
"app_public",
"policy",
"integrations",
"media"
"media",
"billing"
],
"additionalProperties": false
},
@@ -11153,7 +11159,9 @@
"favicon_url": {"nullable": true, "type": "string", "maxLength": 2048},
"theme_color": {"nullable": true, "type": "string", "maxLength": 64},
"status_page_url": {"nullable": true, "type": "string", "maxLength": 2048},
"status_page_incident_history_url": {"nullable": true, "type": "string", "maxLength": 2048}
"status_page_incident_history_url": {"nullable": true, "type": "string", "maxLength": 2048},
"premium_product_name": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 40},
"premium_info_url": {"nullable": true, "type": "string", "maxLength": 2048}
}
},
"setup": {"nullable": true, "type": "object", "properties": {"configured": {"type": "boolean"}}},
@@ -11321,7 +11329,8 @@
}
}
}
}
},
"billing": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/InstanceBillingUpdateRequest"}]}
}
},
"ListGuildStickersResponse": {
@@ -15180,6 +15189,28 @@
{"name": "BANNER_UNSET", "value": "2", "description": "Guild member banner is unset"}
]
},
"InstanceBillingUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"nullable": true, "type": "boolean"},
"stripe_secret_key": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 4096},
"stripe_webhook_secret": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 4096},
"automatic_tax": {"nullable": true, "type": "boolean"},
"tax_id_collection": {"nullable": true, "type": "boolean"},
"terms_consent_required": {"nullable": true, "type": "boolean"},
"default_currency": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/PremiumCurrency"}]},
"prices": {
"nullable": true,
"type": "object",
"additionalProperties": {"$ref": "#/components/schemas/BillingPriceSetUpdateRequest"}
},
"country_currencies": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/BillingCountryCurrenciesSchema"}]
},
"legacy_prices": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/BillingLegacyPricesSchema"}]}
}
},
"InstanceCaptchaProviderSchema": {"type": "string", "enum": ["hcaptcha", "turnstile", "none"]},
"InstanceRegistrationModeSchema": {
"description": "Registration mode",
@@ -15339,6 +15370,85 @@
"type": "string",
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"]
},
"BillingLegacyPricesSchema": {
"type": "object",
"additionalProperties": {
"maxItems": 32,
"type": "array",
"items": {"$ref": "#/components/schemas/StripePriceIdSchema"}
}
},
"BillingCountryCurrenciesSchema": {
"type": "object",
"additionalProperties": {"$ref": "#/components/schemas/PremiumCurrency"}
},
"BillingPriceSetUpdateRequest": {
"type": "object",
"properties": {
"monthly": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
"yearly": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
"gift_1_month": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
"gift_1_year": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]}
}
},
"PremiumCurrency": {"type": "string", "pattern": "^[A-Z]{3}$"},
"StripePriceIdSchema": {"type": "string", "maxLength": 255, "pattern": "^price_[A-Za-z0-9]+$"},
"InstanceBillingResponse": {
"type": "object",
"properties": {
"enabled": {"nullable": true, "type": "boolean"},
"effective_enabled": {"type": "boolean"},
"stripe_secret_key_set": {"type": "boolean"},
"stripe_webhook_secret_set": {"type": "boolean"},
"stripe_secret_key_stored": {"type": "boolean"},
"stripe_webhook_secret_stored": {"type": "boolean"},
"automatic_tax": {"nullable": true, "type": "boolean"},
"tax_id_collection": {"nullable": true, "type": "boolean"},
"terms_consent_required": {"nullable": true, "type": "boolean"},
"effective_automatic_tax": {"type": "boolean"},
"effective_tax_id_collection": {"type": "boolean"},
"effective_terms_consent_required": {"type": "boolean"},
"default_currency": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/PremiumCurrency"}]},
"prices": {
"nullable": true,
"type": "object",
"additionalProperties": {"$ref": "#/components/schemas/BillingPriceSetResponse"}
},
"country_currencies": {"nullable": true, "type": "object", "additionalProperties": {"type": "string"}},
"legacy_prices": {
"nullable": true,
"type": "object",
"additionalProperties": {"type": "array", "items": {"type": "string"}}
},
"billing_active": {"type": "boolean"},
"stripe_serviceable": {"type": "boolean"},
"catalog_mode": {"$ref": "#/components/schemas/BillingCatalogModeSchema"},
"webhook_url": {"type": "string"}
},
"required": [
"enabled",
"effective_enabled",
"stripe_secret_key_set",
"stripe_webhook_secret_set",
"stripe_secret_key_stored",
"stripe_webhook_secret_stored",
"automatic_tax",
"tax_id_collection",
"terms_consent_required",
"effective_automatic_tax",
"effective_tax_id_collection",
"effective_terms_consent_required",
"default_currency",
"prices",
"country_currencies",
"legacy_prices",
"billing_active",
"stripe_serviceable",
"catalog_mode",
"webhook_url"
],
"additionalProperties": false
},
"ExperimentDeliveryConfigResponse": {
"type": "object",
"properties": {
@@ -15609,6 +15719,18 @@
],
"additionalProperties": false
},
"BillingCatalogModeSchema": {"type": "string", "enum": ["env", "operator"]},
"BillingPriceSetResponse": {
"type": "object",
"properties": {
"monthly": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
"yearly": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
"gift_1_month": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
"gift_1_year": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]}
},
"required": ["monthly", "yearly", "gift_1_month", "gift_1_year"],
"additionalProperties": false
},
"JobLedgerEntrySchema": {
"type": "object",
"properties": {
+5
View File
@@ -4,6 +4,7 @@ use super::client::{AdminApiClient, ApiResult};
use super::types::{
CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceConfigResponse,
InstanceConfigUpdateRequest, InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse,
InstancePremiumDiscovery,
};
impl AdminApiClient {
@@ -11,6 +12,10 @@ impl AdminApiClient {
self.get("/admin/instance/config", None).await
}
pub async fn get_instance_premium_discovery(&self) -> ApiResult<InstancePremiumDiscovery> {
self.get("/.well-known/fluxer", None).await
}
pub async fn update_instance_config(
&self,
update: &InstanceConfigUpdateRequest,
@@ -0,0 +1,332 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use super::{InstanceConfigResponse, PremiumMode};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const BILLING_MAX_CURRENCIES: usize = 64;
pub const BILLING_MAX_COUNTRY_CURRENCIES: usize = 300;
pub const BILLING_MAX_LEGACY_SLOTS: usize = 256;
pub const BILLING_MAX_LEGACY_PRICES_PER_SLOT: usize = 32;
pub const BILLING_PRICE_SLOTS: [&str; 4] = ["monthly", "yearly", "gift_1_month", "gift_1_year"];
pub const PREMIUM_PRODUCT_NAME_MAX_CHARS: usize = 40;
pub const TRI_STATE_DEFAULT: &str = "default";
pub const TRI_STATE_ON: &str = "on";
pub const TRI_STATE_OFF: &str = "off";
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BillingCatalogMode {
#[default]
Env,
Operator,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct BillingPriceSet {
pub monthly: Option<String>,
pub yearly: Option<String>,
pub gift_1_month: Option<String>,
pub gift_1_year: Option<String>,
}
impl BillingPriceSet {
pub fn has_recurring_pair(&self) -> bool {
self.monthly.is_some() && self.yearly.is_some()
}
pub fn is_empty(&self) -> bool {
self.monthly.is_none()
&& self.yearly.is_none()
&& self.gift_1_month.is_none()
&& self.gift_1_year.is_none()
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct InstanceBillingResponse {
pub enabled: Option<bool>,
#[serde(default)]
pub effective_enabled: bool,
#[serde(default)]
pub stripe_secret_key_set: bool,
#[serde(default)]
pub stripe_webhook_secret_set: bool,
#[serde(default)]
pub stripe_secret_key_stored: bool,
#[serde(default)]
pub stripe_webhook_secret_stored: bool,
pub default_currency: Option<String>,
pub prices: Option<BTreeMap<String, BillingPriceSet>>,
pub country_currencies: Option<BTreeMap<String, String>>,
pub legacy_prices: Option<BTreeMap<String, Vec<String>>>,
#[serde(default)]
pub billing_active: bool,
#[serde(default)]
pub stripe_serviceable: bool,
#[serde(default)]
pub catalog_mode: BillingCatalogMode,
#[serde(default)]
pub webhook_url: String,
pub automatic_tax: Option<bool>,
pub tax_id_collection: Option<bool>,
pub terms_consent_required: Option<bool>,
#[serde(default)]
pub effective_automatic_tax: bool,
#[serde(default)]
pub effective_tax_id_collection: bool,
#[serde(default)]
pub effective_terms_consent_required: bool,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct InstanceBillingUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<Option<bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stripe_secret_key: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stripe_webhook_secret: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_currency: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prices: Option<Option<BTreeMap<String, BillingPriceSet>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub country_currencies: Option<Option<BTreeMap<String, String>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub legacy_prices: Option<Option<BTreeMap<String, Vec<String>>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub automatic_tax: Option<Option<bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tax_id_collection: Option<Option<bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub terms_consent_required: Option<Option<bool>>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscovery {
#[serde(default)]
pub app_public: InstancePremiumDiscoveryAppPublic,
#[serde(default)]
pub features: InstancePremiumDiscoveryFeatures,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscoveryAppPublic {
#[serde(default)]
pub branding: InstancePremiumDiscoveryBranding,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscoveryBranding {
pub premium_product_name: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscoveryFeatures {
#[serde(default)]
pub premium_enabled: bool,
}
impl InstancePremiumDiscovery {
pub fn premium_product_name(&self) -> Option<&str> {
self.app_public
.branding
.premium_product_name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PremiumBranding {
pub name: Option<String>,
pub premium_enabled: bool,
}
impl PremiumBranding {
pub fn from_discovery(discovery: &InstancePremiumDiscovery) -> Self {
Self {
name: discovery.premium_product_name().map(str::to_owned),
premium_enabled: discovery.features.premium_enabled,
}
}
pub fn from_instance_config(config: &InstanceConfigResponse) -> Self {
Self::from_config_parts(
config.self_hosted,
&config.app_public.branding.premium_product_name,
config.policy.premium_mode,
)
}
fn from_config_parts(self_hosted: bool, name: &str, premium_mode: PremiumMode) -> Self {
let name = name.trim();
Self {
name: (!name.is_empty()).then(|| name.to_owned()),
premium_enabled: !self_hosted || matches!(premium_mode, PremiumMode::Mirror),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::generated::types as generated_types;
use serde_json::json;
#[test]
fn billing_response_round_trips_through_the_generated_contract() {
let value = json!({
"enabled": true,
"effective_enabled": true,
"stripe_secret_key_set": true,
"stripe_webhook_secret_set": false,
"stripe_secret_key_stored": true,
"stripe_webhook_secret_stored": false,
"default_currency": "GBP",
"prices": {
"GBP": {
"monthly": "price_1Monthly",
"yearly": "price_1Yearly",
"gift_1_month": null,
"gift_1_year": null
}
},
"country_currencies": {"GB": "GBP"},
"legacy_prices": {"monthly_GBP": ["price_1Old"]},
"billing_active": false,
"stripe_serviceable": false,
"catalog_mode": "operator",
"webhook_url": "https://api.example.com/stripe/webhook",
"automatic_tax": null,
"tax_id_collection": false,
"terms_consent_required": true,
"effective_automatic_tax": false,
"effective_tax_id_collection": false,
"effective_terms_consent_required": true
});
let generated: generated_types::InstanceBillingResponse =
serde_json::from_value(value.clone()).expect("generated billing response");
let ours: InstanceBillingResponse =
serde_json::from_value(value.clone()).expect("hand-written billing response");
assert_eq!(ours.catalog_mode, BillingCatalogMode::Operator);
assert!(ours.stripe_secret_key_stored);
assert_eq!(ours.automatic_tax, None);
assert_eq!(ours.tax_id_collection, Some(false));
assert!(ours.effective_terms_consent_required);
assert!(ours.prices.as_ref().expect("prices")["GBP"].has_recurring_pair());
assert_eq!(serde_json::to_value(&ours).expect("serializable"), value);
assert_eq!(
serde_json::to_value(generated).expect("serializable generated"),
value
);
}
#[test]
fn default_billing_response_matches_the_generated_contract() {
let value = serde_json::to_value(InstanceBillingResponse::default()).expect("serializable");
serde_json::from_value::<generated_types::InstanceBillingResponse>(value.clone())
.expect("generated billing response");
assert_eq!(value["catalog_mode"], json!("env"));
assert_eq!(value["prices"], json!(null));
}
#[test]
fn billing_update_preserves_explicit_nulls_and_omits_untouched_fields() {
let mut prices = BTreeMap::new();
prices.insert(
"SEK".to_owned(),
BillingPriceSet {
monthly: Some("price_1Monthly".to_owned()),
yearly: Some("price_1Yearly".to_owned()),
..Default::default()
},
);
let update = InstanceBillingUpdateRequest {
enabled: Some(None),
stripe_secret_key: Some(None),
default_currency: Some(None),
prices: Some(Some(prices)),
country_currencies: Some(None),
legacy_prices: Some(Some(BTreeMap::new())),
automatic_tax: Some(None),
tax_id_collection: Some(Some(true)),
terms_consent_required: Some(Some(false)),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::InstanceBillingUpdateRequest>(value.clone())
.expect("generated update contract");
assert_eq!(
value,
json!({
"enabled": null,
"stripe_secret_key": null,
"default_currency": null,
"prices": {
"SEK": {
"monthly": "price_1Monthly",
"yearly": "price_1Yearly",
"gift_1_month": null,
"gift_1_year": null
}
},
"country_currencies": null,
"legacy_prices": {},
"automatic_tax": null,
"tax_id_collection": true,
"terms_consent_required": false
})
);
assert_eq!(
serde_json::to_value(InstanceBillingUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
#[test]
fn premium_discovery_reads_the_name_and_feature_flag() {
let discovery: InstancePremiumDiscovery = serde_json::from_value(json!({
"app_public": {"branding": {"product_name": "Example", "premium_product_name": " Gold "}},
"features": {"premium_enabled": true, "stripe_enabled": false}
}))
.expect("discovery");
assert_eq!(discovery.premium_product_name(), Some("Gold"));
assert!(discovery.features.premium_enabled);
let empty: InstancePremiumDiscovery =
serde_json::from_value(json!({})).expect("empty discovery");
assert_eq!(empty.premium_product_name(), None);
assert!(!empty.features.premium_enabled);
assert_eq!(
PremiumBranding::from_discovery(&discovery),
PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: true
}
);
}
#[test]
fn premium_branding_from_instance_config_matches_discovery_rules() {
assert_eq!(
PremiumBranding::from_config_parts(true, " Gold ", PremiumMode::Everyone),
PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: false
}
);
assert!(
PremiumBranding::from_config_parts(true, "Gold", PremiumMode::Mirror).premium_enabled
);
assert_eq!(
PremiumBranding::from_config_parts(false, " ", PremiumMode::Everyone),
PremiumBranding {
name: None,
premium_enabled: true
}
);
}
}
@@ -2,6 +2,7 @@
use serde::{Deserialize, Serialize};
use super::{InstanceBillingResponse, InstanceBillingUpdateRequest};
pub use crate::api::generated::types::VoiceNoiseSuppressionBackendSchema as NoiseSuppressionBackend;
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -32,6 +33,8 @@ pub struct InstanceConfigResponse {
pub profile_timezone: ProfileTimezoneConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
#[serde(default)]
pub billing: InstanceBillingResponse,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -336,6 +339,9 @@ pub struct AppBrandingConfigResponse {
pub theme_color: Option<String>,
pub status_page_url: Option<String>,
pub status_page_incident_history_url: Option<String>,
#[serde(default = "default_premium_product_name")]
pub premium_product_name: String,
pub premium_info_url: Option<String>,
}
impl Default for AppBrandingConfigResponse {
@@ -350,6 +356,8 @@ impl Default for AppBrandingConfigResponse {
theme_color: None,
status_page_url: None,
status_page_incident_history_url: None,
premium_product_name: default_premium_product_name(),
premium_info_url: None,
}
}
}
@@ -358,6 +366,10 @@ fn default_product_name() -> String {
"Fluxer".to_owned()
}
fn default_premium_product_name() -> String {
"Premium".to_owned()
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct AppSetupConfigResponse {
#[serde(default)]
@@ -855,6 +867,8 @@ pub struct InstanceConfigUpdateRequest {
pub profile_timezone: Option<ProfileTimezoneConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub billing: Option<InstanceBillingUpdateRequest>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -1063,6 +1077,10 @@ pub struct AppBrandingConfigUpdateRequest {
pub status_page_url: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_page_incident_history_url: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub premium_product_name: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub premium_info_url: Option<Option<String>>,
}
#[derive(Clone, Debug, Default, Serialize)]
+2
View File
@@ -9,6 +9,7 @@ mod codes;
mod common;
mod discovery;
mod guild_assets;
mod instance_billing;
mod instance_config;
mod jobs;
mod limit_config;
@@ -28,6 +29,7 @@ pub use codes::*;
pub use common::*;
pub use discovery::*;
pub use guild_assets::*;
pub use instance_billing::*;
pub use instance_config::*;
pub use jobs::*;
pub use limit_config::*;
+597
View File
@@ -0,0 +1,597 @@
// 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<InstanceConfigUpdateRequest, String> {
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<Option<Option<bool>>, 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<T>(result: Result<T, ApiError>) -> 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"),
}
}
}
}
fn validation_message(error: &ApiError) -> Option<String> {
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<Option<String>> {
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<String>) -> Result<Option<String>, 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<String>) -> Result<Option<String>, 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<String, String> {
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<String, String> {
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<String, String> {
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<Option<String>, 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<Option<BTreeMap<String, BillingPriceSet>>, 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<Item = Result<(&str, &str), String>> {
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<Option<BTreeMap<String, String>>, 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<String, String> {
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<Option<BTreeMap<String, Vec<String>>>, String> {
let mut legacy: BTreeMap<String, Vec<String>> = 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::<generated_types::InstanceConfigUpdateRequest>(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);
}
}
+11 -9
View File
@@ -8,12 +8,15 @@ use crate::{
flash::{self, FlashData},
},
state::AppState,
templates::{self, pages::gift_codes::MAX_GIFT_CODES},
templates::{
self,
pages::gift_codes::{GiftCodesPremium, MAX_GIFT_CODES},
},
};
use axum::{
Form, Router,
extract::{FromRequest, Query, Request, State},
response::{Html, IntoResponse, Redirect, Response},
response::{Html, IntoResponse, Response},
routing::get,
};
use serde::Deserialize;
@@ -46,10 +49,11 @@ async fn gift_codes_page(
Query(query): Query<GiftCodesQuery>,
) -> Response {
let config = state.config();
if config.self_hosted {
return Redirect::to(&format!("{}/dashboard", config.base_path)).into_response();
}
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let premium = GiftCodesPremium::from_branding(
config.self_hosted,
state.premium_branding(&client).await.as_ref(),
);
let generated_codes: Option<Vec<String>> = query
.codes
@@ -60,6 +64,7 @@ async fn gift_codes_page(
config,
&auth.0,
&csrf.0.0,
&premium,
generated_codes.as_deref(),
);
Html(markup.into_string()).into_response()
@@ -72,9 +77,6 @@ async fn gift_codes_post(
) -> Response {
let config = state.config();
let base = &config.base_path;
if config.self_hosted {
return Redirect::to(&format!("{base}/dashboard")).into_response();
}
let form: GiftCodesForm = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(error) => {
+1
View File
@@ -5,6 +5,7 @@ pub mod applications;
pub mod auth;
pub mod bans;
mod bans_actions;
mod billing_actions;
pub mod codes;
pub mod discovery;
mod guild_tabs;
+5
View File
@@ -221,6 +221,11 @@ async fn instance_config_page(
.get_instance_config()
.await
.log_error("load instance config");
if let Some(instance_config) = &instance_config {
state.remember_premium_branding(crate::api::types::PremiumBranding::from_instance_config(
instance_config,
));
}
let limit_config = client
.get_limit_config()
.await
+23 -1
View File
@@ -195,7 +195,9 @@ pub async fn instance_config_post(
}
"update_policy" => {
let update = build_policy_update(&form);
instance_config_result(client.update_instance_config(&update).await)
let result = client.update_instance_config(&update).await;
remember_premium_branding(&state, &result);
instance_config_result(result)
}
"update_integrations" => {
let update = build_integrations_update(&form);
@@ -209,6 +211,14 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_billing" => match super::billing_actions::build_billing_update(&form) {
Ok(update) => {
let result = client.update_instance_config(&update).await;
remember_premium_branding(&state, &result);
super::billing_actions::billing_result(result)
}
Err(message) => FlashData::error(message),
},
"update_push_relay" => {
let update = build_push_relay_update(&form);
instance_config_result(client.update_instance_config(&update).await)
@@ -352,6 +362,17 @@ pub async fn instance_config_post(
redirect_back_with_flash(base, "/instance-config", flash, config.secure_cookies())
}
fn remember_premium_branding(
state: &AppState,
result: &Result<crate::api::types::InstanceConfigResponse, crate::api::client::ApiError>,
) {
if let Ok(instance_config) = result {
state.remember_premium_branding(crate::api::types::PremiumBranding::from_instance_config(
instance_config,
));
}
}
fn render_registration_url_list_response(
config: &AdminConfig,
csrf_token: &str,
@@ -867,6 +888,7 @@ fn build_app_public_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
theme_color: optional("app_theme_color"),
status_page_url: optional("app_status_page_url"),
status_page_incident_history_url: optional("app_status_page_incident_history_url"),
..Default::default()
}),
setup: Some(AppSetupConfigUpdateRequest {
configured: Some(form.bool_value("app_setup_configured")),
+118 -33
View File
@@ -4,7 +4,7 @@ use crate::{
acl,
api::{
client::{AdminApiClient, ApiResult, ApiResultExt},
types::AdminUser,
types::{AdminUser, PremiumBranding},
},
middleware::{auth::AuthContext, csrf::CsrfToken, flash, htmx},
routes::user_tabs,
@@ -22,6 +22,7 @@ use axum::{
use serde::Deserialize;
const USER_ID_LOOKUP_BATCH: usize = 100;
const DEFAULT_PREMIUM_NAME: &str = "Premium";
#[derive(Deserialize)]
struct UserListQuery {
@@ -87,32 +88,47 @@ async fn users_list(
.unwrap_or(&[]);
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let results = if params.has_id_lookup() {
lookup_users_in_batches(&client, &params.requested_ids)
.await
.log_error("lookup users by ids")
.map(|users| (users, false))
} else if params.has_search() {
let offset = u64::from(params.page) * u64::from(params.limit);
client
.search_users(
params.search_query(),
params.email_query(),
params.ip_query(),
params.limit,
offset,
)
.await
.log_error("search users")
.map(|r| {
let has_more = (r.users.len() as u64) < r.total.saturating_sub(offset);
(r.users, has_more)
})
} else {
None
let searching = params.has_id_lookup() || params.has_search();
let results = async {
if params.has_id_lookup() {
lookup_users_in_batches(&client, &params.requested_ids)
.await
.log_error("lookup users by ids")
.map(|users| (users, false))
} else if params.has_search() {
let offset = u64::from(params.page) * u64::from(params.limit);
client
.search_users(
params.search_query(),
params.email_query(),
params.ip_query(),
params.limit,
offset,
)
.await
.log_error("search users")
.map(|r| {
let has_more = (r.users.len() as u64) < r.total.saturating_sub(offset);
(r.users, has_more)
})
} else {
None
}
};
let badge = async {
if searching {
self_hosted_premium_badge_name(&state, &client).await
} else {
None
}
};
let (results, badge_name) = tokio::join!(results, badge);
let result_users = results.as_ref().map(|r| r.0.as_slice());
let has_more = results.as_ref().is_some_and(|r| r.1);
let premium_badge_name = match result_users {
Some(users) if !users.is_empty() => badge_name,
_ => None,
};
let markup = templates::pages::users_list::users_list_page(
config,
&auth.0,
@@ -120,11 +136,34 @@ async fn users_list(
result_users,
has_more,
can_view_email,
premium_badge_name.as_deref(),
is_results_fragment,
);
Html(markup.into_string()).into_response()
}
async fn self_hosted_premium_badge_name(
state: &AppState,
client: &AdminApiClient,
) -> Option<String> {
if !state.config().self_hosted {
return None;
}
premium_badge_name(state.premium_branding(client).await.as_ref())
}
fn premium_badge_name(branding: Option<&PremiumBranding>) -> Option<String> {
match branding {
Some(branding) => branding.premium_enabled.then(|| {
branding
.name
.clone()
.unwrap_or_else(|| DEFAULT_PREMIUM_NAME.to_owned())
}),
None => Some(DEFAULT_PREMIUM_NAME.to_owned()),
}
}
async fn lookup_users_in_batches(
client: &AdminApiClient,
user_ids: &[String],
@@ -148,10 +187,15 @@ async fn user_detail(
let is_detail_fragment = htmx::targets(&headers, "main-content");
let active_tab = query.tab.as_deref().unwrap_or("overview");
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let user = client
.get_user_by_id(&user_id)
.await
.log_error("load user detail");
let (user, badge_name) = tokio::join!(
async {
client
.get_user_by_id(&user_id)
.await
.log_error("load user detail")
},
self_hosted_premium_badge_name(&state, &client)
);
let tq = to_tab_query(&query);
let admin_acls = auth
.0
@@ -167,6 +211,7 @@ async fn user_detail(
} else {
None
};
let premium_badge_name = user.as_ref().and(badge_name);
let markup = templates::pages::user_detail::user_detail_with_tab(
config,
&auth.0,
@@ -174,6 +219,7 @@ async fn user_detail(
&user_id,
active_tab,
tab_body,
premium_badge_name.as_deref(),
is_detail_fragment,
);
Html(markup.into_string()).into_response()
@@ -275,18 +321,29 @@ async fn user_peek(
) -> Response {
let config = state.config();
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let user = client
.get_user_by_id(&user_id)
.await
.log_error("load user peek");
let (user, badge_name) = tokio::join!(
async {
client
.get_user_by_id(&user_id)
.await
.log_error("load user peek")
},
self_hosted_premium_badge_name(&state, &client)
);
let admin_acls = auth
.0
.admin_user
.as_ref()
.map(|user| user.acls.as_slice())
.unwrap_or(&[]);
let premium_badge_name = user.as_ref().and(badge_name);
let markup = match user {
Some(ref u) => templates::pages::user_peek::user_peek_fragment(config, u, admin_acls),
Some(ref u) => templates::pages::user_peek::user_peek_fragment(
config,
u,
admin_acls,
premium_badge_name.as_deref(),
),
None => maud::html! {
div class="p-4 text-red-600 text-sm" { "User not found." }
},
@@ -319,3 +376,31 @@ fn append_query_params(url: &mut String, params: &[(String, String)]) {
url.push_str(&urlencoding::encode(value));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn badge_name_follows_the_cached_branding_and_falls_back_to_the_default() {
let gold = PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: true,
};
assert_eq!(premium_badge_name(Some(&gold)).as_deref(), Some("Gold"));
let unnamed = PremiumBranding {
name: None,
premium_enabled: true,
};
assert_eq!(
premium_badge_name(Some(&unnamed)).as_deref(),
Some("Premium")
);
let everyone = PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: false,
};
assert_eq!(premium_badge_name(Some(&everyone)), None);
assert_eq!(premium_badge_name(None).as_deref(), Some("Premium"));
}
}
+49 -2
View File
@@ -1,7 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::config::AdminConfig;
use std::sync::Arc;
use crate::{
api::{
client::{AdminApiClient, ApiResultExt},
types::PremiumBranding,
},
config::AdminConfig,
};
use std::{
sync::{Arc, Mutex},
time::{Duration, Instant},
};
const PREMIUM_BRANDING_TTL: Duration = Duration::from_secs(60);
#[derive(Clone)]
pub struct AppState {
@@ -11,6 +22,7 @@ pub struct AppState {
struct AppStateInner {
pub config: AdminConfig,
pub http_client: reqwest::Client,
premium_branding: Mutex<Option<(Instant, PremiumBranding)>>,
}
impl AppState {
@@ -23,6 +35,7 @@ impl AppState {
inner: Arc::new(AppStateInner {
config,
http_client,
premium_branding: Mutex::new(None),
}),
}
}
@@ -34,6 +47,40 @@ impl AppState {
pub fn http_client(&self) -> &reqwest::Client {
&self.inner.http_client
}
pub fn cached_premium_branding(&self) -> Option<PremiumBranding> {
let cache = self
.inner
.premium_branding
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
cache
.as_ref()
.filter(|(fetched_at, _)| fetched_at.elapsed() < PREMIUM_BRANDING_TTL)
.map(|(_, branding)| branding.clone())
}
pub fn remember_premium_branding(&self, branding: PremiumBranding) {
*self
.inner
.premium_branding
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((Instant::now(), branding));
}
pub async fn premium_branding(&self, client: &AdminApiClient) -> Option<PremiumBranding> {
if let Some(branding) = self.cached_premium_branding() {
return Some(branding);
}
let branding = PremiumBranding::from_discovery(
&client
.get_instance_premium_discovery()
.await
.log_error("load premium branding")?,
);
self.remember_premium_branding(branding.clone());
Some(branding)
}
}
impl axum::extract::FromRef<AppState> for AdminConfig {
@@ -13,12 +13,39 @@ struct BadgeDef {
tooltip: String,
}
fn premium_tooltip(
premium_type: i32,
premium_since: Option<&str>,
is_self_hosted: bool,
self_hosted_premium_name: Option<&str>,
) -> Option<String> {
if is_self_hosted {
let name = self_hosted_premium_name?;
return Some(match premium_since {
Some(since) => format!("{name} subscriber since {since}"),
None => name.to_owned(),
});
}
Some(if premium_type == premium_types::LIFETIME {
match premium_since {
Some(since) => format!("Fluxer Visionary since {since}"),
None => "Fluxer Visionary".into(),
}
} else {
match premium_since {
Some(since) => format!("Fluxer Plutonium subscriber since {since}"),
None => "Fluxer Plutonium".into(),
}
})
}
pub fn user_profile_badges(
static_cdn_endpoint: &str,
flags: u64,
premium_type: Option<i32>,
premium_since: Option<&str>,
is_self_hosted: bool,
self_hosted_premium_name: Option<&str>,
size_sm: bool,
) -> Markup {
let cdn = static_cdn_endpoint.trim_end_matches('/');
@@ -42,23 +69,11 @@ pub fn user_profile_badges(
tooltip: "Fluxer Bug Hunter".into(),
});
}
if !is_self_hosted
&& let Some(pt) = premium_type
if let Some(pt) = premium_type
&& pt != premium_types::NONE
&& let Some(tooltip) =
premium_tooltip(pt, premium_since, is_self_hosted, self_hosted_premium_name)
{
let tooltip = if pt == premium_types::LIFETIME {
match premium_since {
Some(since) => format!("Fluxer Visionary since {since}"),
None => "Fluxer Visionary".into(),
}
} else {
match premium_since {
Some(since) => {
format!("Fluxer Plutonium subscriber since {since}")
}
None => "Fluxer Plutonium".into(),
}
};
badges.push(BadgeDef {
icon_url: format!("{cdn}/badges/plutonium.svg"),
tooltip,
@@ -84,3 +99,38 @@ pub fn user_profile_badges(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn render(self_hosted: bool, name: Option<&str>, premium_type: i32) -> String {
user_profile_badges(
"https://static.example.com",
0,
Some(premium_type),
Some("2026-01-01"),
self_hosted,
name,
false,
)
.into_string()
}
#[test]
fn hosted_premium_badges_keep_their_fluxer_labels() {
assert!(
render(false, Some("Gold"), 1).contains("Fluxer Plutonium subscriber since 2026-01-01")
);
assert!(render(false, None, 2).contains("Fluxer Visionary since 2026-01-01"));
}
#[test]
fn self_hosted_premium_badges_use_the_configured_name() {
let markup = render(true, Some("Gold"), 1);
assert!(markup.contains("Gold subscriber since 2026-01-01"));
assert!(!markup.contains("Plutonium"));
assert!(render(true, Some("Gold"), 2).contains("Gold subscriber since"));
assert!(!render(true, None, 1).contains("img"));
}
}
@@ -255,13 +255,12 @@ pub const NAV_SECTIONS: &[NavSection] = &[
],
},
NavSection {
title: "Hosted Features",
title: "Premium",
items: &[item!(
"Gift Codes",
"/gift-codes",
"gift-codes",
[acl::GIFT_CODES_GENERATE],
hosted
[acl::GIFT_CODES_GENERATE]
)],
},
];
+73 -5
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::types::PremiumBranding,
config::AdminConfig,
middleware::auth::AuthContext,
templates::{
@@ -19,21 +20,52 @@ use maud::{Markup, html};
pub const MAX_GIFT_CODES: u32 = 100;
const DEFAULT_GIFT_COUNT: u32 = 10;
pub struct GiftCodesPremium {
pub name: String,
pub needs_mirror_mode: bool,
}
impl GiftCodesPremium {
pub fn from_branding(self_hosted: bool, branding: Option<&PremiumBranding>) -> Self {
let default_name = if self_hosted { "Premium" } else { "Plutonium" };
Self {
name: branding
.and_then(|branding| branding.name.as_deref())
.unwrap_or(default_name)
.to_owned(),
needs_mirror_mode: self_hosted
&& branding.is_some_and(|branding| !branding.premium_enabled),
}
}
}
pub fn gift_codes_page(
config: &AdminConfig,
auth: &AuthContext,
csrf_token: &str,
premium: &GiftCodesPremium,
generated_codes: Option<&[String]>,
) -> Markup {
let base = &config.base_path;
let codes_value = generated_codes.map(|c| c.join("\n")).unwrap_or_default();
let description = format!(
"Create one-use {} gift URLs with a fixed positive duration. \
Lifetime gifts cannot be generated here.",
premium.name
);
let content = html! {
(page_header(
"Gift Codes",
Some("Create one-use Plutonium gift URLs with a fixed positive \
duration. Lifetime gifts cannot be generated here."),
))
(page_header("Gift Codes", Some(&description)))
@if premium.needs_mirror_mode {
(card(html! {
p class="text-sm text-amber-700" {
"The premium model is Everyone, so every member already has " (premium.name)
" and gift codes cannot be generated or redeemed. Switch the premium model to \
Mirror in Instance Config to use gift codes."
}
}))
}
(card(html! {
div class="flex flex-col gap-4" {
@@ -107,3 +139,39 @@ pub fn gift_codes_page(
};
admin_layout(config, auth, "Gift Codes", "gift-codes", None, content)
}
#[cfg(test)]
mod tests {
use super::*;
fn branding(name: &str, premium_enabled: bool) -> PremiumBranding {
PremiumBranding {
name: Some(name.to_owned()),
premium_enabled,
}
}
#[test]
fn premium_name_comes_from_branding_with_per_deployment_fallbacks() {
let hosted = GiftCodesPremium::from_branding(false, None);
assert_eq!(hosted.name, "Plutonium");
assert!(!hosted.needs_mirror_mode);
let self_hosted = GiftCodesPremium::from_branding(true, None);
assert_eq!(self_hosted.name, "Premium");
assert!(!self_hosted.needs_mirror_mode);
let gold = GiftCodesPremium::from_branding(true, Some(&branding("Gold", true)));
assert_eq!(gold.name, "Gold");
assert!(!gold.needs_mirror_mode);
}
#[test]
fn everyone_mode_is_only_flagged_on_self_hosted_instances() {
assert!(
GiftCodesPremium::from_branding(true, Some(&branding("Gold", false))).needs_mirror_mode
);
assert!(
!GiftCodesPremium::from_branding(false, Some(&branding("Plutonium", false)))
.needs_mirror_mode
);
}
}
@@ -0,0 +1,652 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::types::{
AppBrandingConfigResponse, BillingCatalogMode, BillingPriceSet, InstanceBillingResponse,
PremiumMode, TRI_STATE_DEFAULT, TRI_STATE_OFF, TRI_STATE_ON,
},
templates::components::{
badge::{BadgeVariant, badge},
form::{
FORM_INPUT_CLASS, FORM_SELECT_CLASS, checkbox, csrf_input, form_actions,
form_field_group, select_chevron, submit_button, text_input, textarea_input,
},
section_card::section_card_with_description,
},
};
use maud::{Markup, html};
const PRICE_COLUMNS: [(&str, &str); 4] = [
("billing_price_monthly", "Monthly"),
("billing_price_yearly", "Yearly"),
("billing_price_gift_1_month", "Gift 1 month"),
("billing_price_gift_1_year", "Gift 1 year"),
];
pub fn billing_blockers(
billing: &InstanceBillingResponse,
premium_mode: PremiumMode,
) -> Vec<&'static str> {
if billing.billing_active {
return Vec::new();
}
let mut blockers = Vec::new();
if matches!(premium_mode, PremiumMode::Everyone) {
blockers.push("the premium model is Everyone, so there is no paid tier to sell");
}
if !billing.effective_enabled {
blockers.push("billing is not enabled");
}
if !billing.stripe_secret_key_set {
blockers.push("no Stripe secret key is set");
}
let has_pair = billing
.prices
.as_ref()
.is_some_and(|prices| prices.values().any(BillingPriceSet::has_recurring_pair));
if billing.catalog_mode == BillingCatalogMode::Operator && !has_pair {
blockers.push("no currency has both a monthly and a yearly price ID");
}
if blockers.is_empty() {
blockers.push(match billing.catalog_mode {
BillingCatalogMode::Env => {
"the environment price catalog has no currency with both a monthly and a yearly price ID"
}
BillingCatalogMode::Operator => "the API reports billing as inactive",
});
}
blockers
}
fn billing_status(billing: &InstanceBillingResponse, premium_mode: PremiumMode) -> Markup {
let blockers = billing_blockers(billing, premium_mode);
html! {
div class="space-y-2" {
div class="flex flex-wrap items-center gap-2" {
@if billing.billing_active {
(badge("Billing active", BadgeVariant::Success))
} @else {
(badge("Billing inactive", BadgeVariant::Default))
}
@match billing.catalog_mode {
BillingCatalogMode::Operator => {
(badge("Catalog: price table", BadgeVariant::Default))
}
BillingCatalogMode::Env => {
(badge("Catalog: environment", BadgeVariant::Default))
}
}
(secret_badge(
"Stripe secret key",
billing.stripe_secret_key_set,
billing.stripe_secret_key_stored,
BadgeVariant::Default,
))
(secret_badge(
"Webhook secret",
billing.stripe_webhook_secret_set,
billing.stripe_webhook_secret_stored,
BadgeVariant::Warning,
))
}
@if !blockers.is_empty() {
p class="text-sm text-neutral-600" {
"Purchases are unavailable because " (blockers.join("; ")) "."
}
}
@if billing.billing_active && !billing.stripe_webhook_secret_set {
p class="text-sm text-amber-700" {
"Without a webhook secret, Stripe events are rejected, so subscriptions never reach accounts."
}
}
}
}
}
fn secret_badge(label: &str, is_set: bool, is_stored: bool, missing: BadgeVariant) -> Markup {
match (is_set, is_stored) {
(_, true) => badge(&format!("{label} set"), BadgeVariant::Success),
(true, false) => badge(&format!("{label} from environment"), BadgeVariant::Success),
(false, false) => badge(&format!("{label} missing"), missing),
}
}
fn secret_field(
name: &str,
clear_name: &str,
label: &str,
is_set: bool,
is_stored: bool,
) -> Markup {
let helper = if !is_stored && is_set {
"Set from the environment. Enter a value to override it, or leave blank to keep using it."
} else {
"Leave blank to keep the current value."
};
html! {
div class="flex flex-col gap-2" {
(form_field_group(
label,
name,
false,
None,
Some(helper),
html! {
input type="password" id=(name) name=(name) value="" class=(FORM_INPUT_CLASS)
autocomplete="new-password";
},
))
@if is_stored {
(checkbox(clear_name, "true", "Clear the stored value", false, true))
}
}
}
}
fn tri_state_value(value: Option<bool>) -> &'static str {
match value {
None => TRI_STATE_DEFAULT,
Some(true) => TRI_STATE_ON,
Some(false) => TRI_STATE_OFF,
}
}
fn tri_state_select(
name: &str,
label: &str,
default_label: &str,
stored: Option<bool>,
helper: &str,
) -> Markup {
let selected = tri_state_value(stored);
let options = [
(TRI_STATE_DEFAULT, default_label),
(TRI_STATE_ON, "On"),
(TRI_STATE_OFF, "Off"),
];
form_field_group(
label,
name,
false,
None,
Some(helper),
html! {
div class="relative" {
select id=(name) name=(name) class=(FORM_SELECT_CLASS) {
@for (value, display) in options {
option value=(value) selected[value == selected] { (display) }
}
}
(select_chevron())
}
},
)
}
fn on_off(value: bool) -> &'static str {
if value { "on" } else { "off" }
}
fn checkout_options(billing: &InstanceBillingResponse) -> Markup {
let automatic_tax = format!(
"Calculates tax at checkout. Needs Stripe Tax activated and a head office address in the Stripe dashboard. Currently {}.",
on_off(billing.effective_automatic_tax)
);
let tax_id = format!(
"Lets buyers add a VAT or other tax ID at checkout. Pair it with automatic tax. Currently {}.",
on_off(billing.effective_tax_id_collection)
);
let terms = format!(
"Buyers must accept your terms of service at checkout. Needs a terms of service URL in the Stripe dashboard public details. Currently {}.",
on_off(billing.effective_terms_consent_required)
);
html! {
div class="space-y-4" {
h4 class="text-sm font-medium text-neutral-900" { "Checkout options" }
div class="grid grid-cols-1 gap-4 sm:grid-cols-3" {
(tri_state_select(
"billing_automatic_tax",
"Automatic tax",
"Use default",
billing.automatic_tax,
&automatic_tax,
))
(tri_state_select(
"billing_tax_id_collection",
"Tax ID collection",
"Use default",
billing.tax_id_collection,
&tax_id,
))
(tri_state_select(
"billing_terms_consent_required",
"Terms consent",
"Use default",
billing.terms_consent_required,
&terms,
))
}
p class="text-xs text-neutral-500" {
"Members manage and cancel subscriptions in the Stripe customer portal. It only opens after you save its \
settings once in the Stripe dashboard under Settings, Billing, Customer portal."
}
}
}
}
fn price_cell(name: &str, label: &str, value: Option<&str>) -> Markup {
html! {
td class="px-2 py-2" {
input type="text" name=(name) value=(value.unwrap_or(""))
placeholder="price_..." aria-label=(label)
autocomplete="off" spellcheck="false"
class=(FORM_INPUT_CLASS);
}
}
}
fn price_row(currency: &str, set: &BillingPriceSet) -> Markup {
let values = [
set.monthly.as_deref(),
set.yearly.as_deref(),
set.gift_1_month.as_deref(),
set.gift_1_year.as_deref(),
];
html! {
tr {
td class="px-2 py-2" {
input type="text" name="billing_price_currency" value=(currency)
placeholder="GBP" maxlength="3" aria-label="Currency"
autocomplete="off" spellcheck="false"
class={(FORM_INPUT_CLASS) " w-24 uppercase"};
}
@for ((name, label), value) in PRICE_COLUMNS.iter().zip(values) {
(price_cell(name, label, value))
}
}
}
}
fn price_table(billing: &InstanceBillingResponse) -> Markup {
let empty = BillingPriceSet::default();
html! {
div class="space-y-2" {
h4 class="text-sm font-medium text-neutral-900" { "Prices" }
p class="text-xs text-neutral-500" {
"One row per currency, using Stripe price IDs from your own account. Monthly and yearly are the \
subscription prices; the gift prices are one-time prices for buying gifts. Clear a currency to \
remove its row. Leave the table empty to use the prices from environment variables."
}
div class="overflow-x-auto" {
table class="min-w-full text-sm" {
thead {
tr class="text-left text-xs text-neutral-500" {
th class="px-2 py-1 font-medium" { "Currency" }
@for (_, label) in PRICE_COLUMNS {
th class="px-2 py-1 font-medium" { (label) }
}
}
}
tbody {
@if let Some(prices) = &billing.prices {
@for (currency, set) in prices {
(price_row(currency, set))
}
}
(price_row("", &empty))
}
}
}
}
}
}
fn country_currencies_text(billing: &InstanceBillingResponse) -> String {
billing
.country_currencies
.iter()
.flatten()
.map(|(country, currency)| format!("{country}={currency}"))
.collect::<Vec<_>>()
.join("\n")
}
fn legacy_prices_text(billing: &InstanceBillingResponse) -> String {
billing
.legacy_prices
.iter()
.flatten()
.flat_map(|(slot, ids)| ids.iter().map(move |id| format!("{slot}={id}")))
.collect::<Vec<_>>()
.join("\n")
}
pub fn premium_billing_section(
base: &str,
csrf_token: &str,
branding: &AppBrandingConfigResponse,
billing: &InstanceBillingResponse,
premium_mode: PremiumMode,
) -> Markup {
let enabled_helper = format!(
"Use environment setting follows FLUXER_STRIPE_ENABLED or the config file. Billing is currently {}.",
on_off(billing.effective_enabled)
);
section_card_with_description(
"Premium & Billing",
"Name the premium tier and sell it through your own Stripe account. Subscriptions and gift purchases need \
the Mirror premium model, a Stripe secret key and at least one currency with monthly and yearly prices.",
html! {
form method="post" action={(base) "/instance-config?action=update_billing"}
data-admin-result-form="true" {
(csrf_input(csrf_token))
div class="space-y-8" {
div class="space-y-4" {
h3 class="text-sm font-semibold text-neutral-900" { "Premium tier" }
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(text_input(
"billing_premium_product_name",
"Premium name",
&branding.premium_product_name,
"Premium",
))
(text_input(
"billing_premium_info_url",
"Premium info URL",
branding.premium_info_url.as_deref().unwrap_or(""),
"https://example.com/premium",
))
}
p class="text-xs text-neutral-500" {
"Clients show this name wherever the premium tier is mentioned. Clear it to use the default. \
The info URL is an optional page that describes the tier."
}
@if matches!(premium_mode, PremiumMode::Everyone) {
p class="text-sm text-amber-700" {
"The premium model is Everyone, so every member already has premium limits and clients hide \
premium. Switch the premium model to Mirror to sell subscriptions or redeem gift codes."
}
}
}
div class="space-y-4 border-t border-neutral-200 pt-6" {
h3 class="text-sm font-semibold text-neutral-900" { "Stripe" }
(billing_status(billing, premium_mode))
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(tri_state_select(
"billing_enabled",
"Billing",
"Use environment setting",
billing.enabled,
&enabled_helper,
))
}
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(secret_field(
"billing_stripe_secret_key",
"billing_clear_stripe_secret_key",
"Stripe secret key",
billing.stripe_secret_key_set,
billing.stripe_secret_key_stored,
))
(secret_field(
"billing_stripe_webhook_secret",
"billing_clear_stripe_webhook_secret",
"Stripe webhook signing secret",
billing.stripe_webhook_secret_set,
billing.stripe_webhook_secret_stored,
))
}
(form_field_group(
"Webhook URL",
"billing_webhook_url",
false,
None,
Some("Add this endpoint in the Stripe dashboard, then paste its signing secret above."),
html! {
input type="text" id="billing_webhook_url" value=(billing.webhook_url)
readonly class=(FORM_INPUT_CLASS);
},
))
(checkout_options(billing))
}
div class="space-y-4 border-t border-neutral-200 pt-6" {
h3 class="text-sm font-semibold text-neutral-900" { "Catalog" }
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(text_input(
"billing_default_currency",
"Default currency",
billing.default_currency.as_deref().unwrap_or(""),
"GBP",
))
}
p class="text-xs text-neutral-500" {
"Used when a buyer's country has no mapping below. Leave blank to use the first currency in the table."
}
(price_table(billing))
div class="grid grid-cols-1 gap-4 lg:grid-cols-2" {
div class="space-y-2" {
(textarea_input(
"billing_country_currencies",
"Country currencies",
"SE=SEK\nGB=GBP",
&country_currencies_text(billing),
6,
false,
))
p class="text-xs text-neutral-500" {
"One COUNTRY=CURRENCY per line, using 2-letter country codes. Each currency needs a row in the table."
}
}
div class="space-y-2" {
(textarea_input(
"billing_legacy_prices",
"Legacy prices",
"monthly_GBP=price_...",
&legacy_prices_text(billing),
6,
false,
))
p class="text-xs text-neutral-500" {
"Older price IDs that existing subscribers may still be on, one SLOT_CURRENCY=price ID per line. \
Repeat a slot for several IDs. Slots are monthly, yearly, gift_1_month and gift_1_year."
}
}
}
}
(form_actions(html! {
(submit_button("Save premium & billing"))
}))
}
}
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn operator_billing() -> InstanceBillingResponse {
InstanceBillingResponse {
enabled: Some(true),
effective_enabled: true,
stripe_secret_key_set: true,
stripe_webhook_secret_set: true,
stripe_secret_key_stored: true,
stripe_webhook_secret_stored: true,
default_currency: Some("GBP".to_owned()),
prices: Some(BTreeMap::from([(
"GBP".to_owned(),
BillingPriceSet {
monthly: Some("price_1GbpM".to_owned()),
yearly: Some("price_1GbpY".to_owned()),
gift_1_month: None,
gift_1_year: Some("price_1GbpG".to_owned()),
},
)])),
country_currencies: Some(BTreeMap::from([
("GB".to_owned(), "GBP".to_owned()),
("IE".to_owned(), "GBP".to_owned()),
])),
legacy_prices: Some(BTreeMap::from([(
"monthly_GBP".to_owned(),
vec!["price_1OldA".to_owned(), "price_1OldB".to_owned()],
)])),
billing_active: true,
stripe_serviceable: true,
catalog_mode: BillingCatalogMode::Operator,
webhook_url: "https://api.example.com/stripe/webhook".to_owned(),
automatic_tax: None,
tax_id_collection: Some(true),
terms_consent_required: Some(false),
effective_automatic_tax: false,
effective_tax_id_collection: true,
effective_terms_consent_required: false,
}
}
fn branding(name: &str) -> AppBrandingConfigResponse {
AppBrandingConfigResponse {
premium_product_name: name.to_owned(),
premium_info_url: Some("https://example.com/gold".to_owned()),
..Default::default()
}
}
#[test]
fn section_renders_every_field_and_one_empty_price_row() {
let markup = premium_billing_section(
"/admin",
"csrf",
&branding("Gold"),
&operator_billing(),
PremiumMode::Mirror,
)
.into_string();
assert!(markup.contains("action=\"/admin/instance-config?action=update_billing\""));
assert!(markup.contains("data-admin-result-form=\"true\""));
assert!(markup.contains("<option value=\"on\" selected>On</option>"));
assert!(markup.contains("name=\"billing_automatic_tax\""));
assert!(markup.contains("name=\"billing_tax_id_collection\""));
assert!(markup.contains("name=\"billing_terms_consent_required\""));
assert!(markup.contains("Customer portal"));
assert!(markup.contains("name=\"billing_premium_product_name\""));
assert!(markup.contains("value=\"Gold\""));
assert!(markup.contains("value=\"https://example.com/gold\""));
assert!(markup.contains("name=\"billing_enabled\""));
assert!(markup.contains("type=\"password\" id=\"billing_stripe_secret_key\""));
assert!(markup.contains("name=\"billing_clear_stripe_secret_key\""));
assert!(markup.contains("name=\"billing_clear_stripe_webhook_secret\""));
assert!(markup.contains("value=\"https://api.example.com/stripe/webhook\""));
assert!(markup.contains("Billing active"));
assert!(!markup.contains("Purchases are unavailable"));
assert_eq!(markup.matches("name=\"billing_price_currency\"").count(), 2);
assert_eq!(
markup.matches("name=\"billing_price_gift_1_year\"").count(),
2
);
assert!(markup.contains("value=\"price_1GbpG\""));
assert!(markup.contains("GB=GBP\nIE=GBP"));
assert!(markup.contains("monthly_GBP=price_1OldA\nmonthly_GBP=price_1OldB"));
assert!(!markup.contains("Plutonium"));
assert!(!markup.contains("sk_"));
}
#[test]
fn unset_secrets_have_no_clear_checkbox() {
let billing = InstanceBillingResponse::default();
let markup = premium_billing_section(
"/admin",
"csrf",
&branding("Premium"),
&billing,
PremiumMode::Everyone,
)
.into_string();
assert!(!markup.contains("billing_clear_stripe_secret_key"));
assert!(!markup.contains("billing_clear_stripe_webhook_secret"));
assert_eq!(markup.matches("name=\"billing_price_currency\"").count(), 1);
assert!(markup.contains("Switch the premium model to Mirror"));
assert!(markup.contains("Catalog: environment"));
}
#[test]
fn env_secrets_are_labelled_and_cannot_be_cleared() {
let billing = InstanceBillingResponse {
stripe_secret_key_set: true,
stripe_webhook_secret_set: true,
..Default::default()
};
let markup = premium_billing_section(
"/admin",
"csrf",
&branding("Premium"),
&billing,
PremiumMode::Mirror,
)
.into_string();
assert!(markup.contains("Stripe secret key from environment"));
assert!(markup.contains("Webhook secret from environment"));
assert!(markup.contains("Set from the environment"));
assert!(!markup.contains("billing_clear_stripe_secret_key"));
assert!(!markup.contains("billing_clear_stripe_webhook_secret"));
}
#[test]
fn tri_state_selects_reflect_the_stored_value() {
let render = |stored| {
tri_state_select(
"billing_enabled",
"Billing",
"Use environment setting",
stored,
"",
)
.into_string()
};
assert!(
render(None)
.contains("<option value=\"default\" selected>Use environment setting</option>")
);
assert!(render(Some(true)).contains("<option value=\"on\" selected>On</option>"));
assert!(render(Some(false)).contains("<option value=\"off\" selected>Off</option>"));
}
#[test]
fn blockers_explain_why_billing_is_inactive() {
let mut billing = InstanceBillingResponse::default();
assert_eq!(
billing_blockers(&billing, PremiumMode::Everyone),
vec![
"the premium model is Everyone, so there is no paid tier to sell",
"billing is not enabled",
"no Stripe secret key is set",
]
);
billing.effective_enabled = true;
billing.stripe_secret_key_set = true;
assert_eq!(
billing_blockers(&billing, PremiumMode::Mirror),
vec![
"the environment price catalog has no currency with both a monthly and a yearly price ID"
]
);
billing.catalog_mode = BillingCatalogMode::Operator;
billing.prices = Some(BTreeMap::from([(
"GBP".to_owned(),
BillingPriceSet {
monthly: Some("price_1A".to_owned()),
..Default::default()
},
)]));
assert_eq!(
billing_blockers(&billing, PremiumMode::Mirror),
vec!["no currency has both a monthly and a yearly price ID"]
);
assert!(billing_blockers(&operator_billing(), PremiumMode::Mirror).is_empty());
}
}
@@ -26,6 +26,7 @@ use crate::{
section_card::{section_card_simple, section_card_with_description},
},
layout::admin_layout,
pages::instance_billing::premium_billing_section,
},
utils::timestamps::format_admin_timestamp,
};
@@ -127,7 +128,25 @@ pub fn instance_config_page(
"Community & policy",
"Community shape, direct messaging, the premium model, and optional embed services.",
html! {
(policy_config_section(base, csrf_token, &instance_config.policy))
(policy_config_section(
base,
csrf_token,
&instance_config.policy,
&instance_config.app_public.branding.premium_product_name,
))
},
))
(config_group(
"Premium & billing",
"The premium tier's name, Stripe credentials and the prices members pay.",
html! {
(premium_billing_section(
base,
csrf_token,
&instance_config.app_public.branding,
&instance_config.billing,
instance_config.policy.premium_mode,
))
},
))
}
@@ -209,7 +228,12 @@ fn config_group(title: &str, description: &str, content: Markup) -> Markup {
}
}
fn policy_config_section(base: &str, csrf_token: &str, policy: &InstancePolicyResponse) -> Markup {
fn policy_config_section(
base: &str,
csrf_token: &str,
policy: &InstancePolicyResponse,
premium_name: &str,
) -> Markup {
section_card_with_description(
"Community & Policy",
"Control whether this instance runs as a single community, whether direct messages and \
@@ -219,7 +243,7 @@ fn policy_config_section(base: &str, csrf_token: &str, policy: &InstancePolicyRe
div class="space-y-8" {
(single_community_form(base, csrf_token, policy))
(direct_messages_form(base, csrf_token, policy))
(premium_mode_form(base, csrf_token, policy))
(premium_mode_form(base, csrf_token, policy, premium_name))
(services_form(base, csrf_token, policy))
}
},
@@ -363,7 +387,14 @@ fn deferred_phone_gate_form(
}
}
fn premium_mode_form(base: &str, csrf_token: &str, policy: &InstancePolicyResponse) -> Markup {
fn premium_mode_form(
base: &str,
csrf_token: &str,
policy: &InstancePolicyResponse,
premium_name: &str,
) -> Markup {
let mirror_label = format!("Mirror (Free and {premium_name} tiers)");
let everyone_label = format!("Everyone (every member gets {premium_name} limits)");
html! {
div class="space-y-4 border-t border-neutral-200 pt-6" {
h3 class="text-sm font-semibold text-neutral-900" { "Premium model" }
@@ -371,8 +402,8 @@ fn premium_mode_form(base: &str, csrf_token: &str, policy: &InstancePolicyRespon
(csrf_input(csrf_token))
div class="space-y-4" {
(select_input("policy_premium_mode", "Premium model", &[
("mirror", "Mirror (Free and Premium tiers)"),
("everyone", "Everyone (every member gets Plutonium limits)"),
("mirror", mirror_label.as_str()),
("everyone", everyone_label.as_str()),
], policy.premium_mode.as_str()))
(form_actions(html! {
(submit_button("Save premium model"))
@@ -2356,7 +2387,7 @@ fn sso_config_section(base: &str, csrf_token: &str, sso: &SsoConfigResponse) ->
fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Markup {
let description = if limit_config.self_hosted.unwrap_or(false) {
"Self-hosted instance with all premium features enabled. Configure user and guild limits."
"Self-hosted instance with all premium features enabled by default. Configure user and guild limits."
} else {
"Configure limit rules that control user and guild restrictions based on traits and features."
};
@@ -2454,6 +2485,16 @@ mod tests {
assert!(unaccepted.contains("value=\"Nobody\""));
}
#[test]
fn premium_mode_options_use_the_configured_premium_name() {
let markup =
premium_mode_form("/admin", "csrf", &InstancePolicyResponse::default(), "Gold")
.into_string();
assert!(markup.contains("Mirror (Free and Gold tiers)"));
assert!(markup.contains("Everyone (every member gets Gold limits)"));
assert!(!markup.contains("Plutonium"));
}
#[test]
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
+1
View File
@@ -17,6 +17,7 @@ pub mod gift_codes;
pub mod guild_detail;
pub mod guild_detail_tabs;
pub mod guilds_list;
pub mod instance_billing;
pub mod instance_config;
pub mod job_detail;
pub mod jobs_list;
@@ -38,11 +38,22 @@ pub fn user_detail_page(
auth: &AuthContext,
user: Option<&AdminUser>,
user_id: &str,
premium_badge_name: Option<&str>,
is_htmx: bool,
) -> Markup {
user_detail_with_tab(config, auth, user, user_id, "overview", None, is_htmx)
user_detail_with_tab(
config,
auth,
user,
user_id,
"overview",
None,
premium_badge_name,
is_htmx,
)
}
#[allow(clippy::too_many_arguments)]
pub fn user_detail_with_tab(
config: &AdminConfig,
auth: &AuthContext,
@@ -50,10 +61,13 @@ pub fn user_detail_with_tab(
user_id: &str,
active_tab: &str,
tab_body: Option<Markup>,
premium_badge_name: Option<&str>,
is_htmx: bool,
) -> Markup {
let content = match user {
Some(user) => render_user_detail(config, auth, user, active_tab, tab_body),
Some(user) => {
render_user_detail(config, auth, user, active_tab, tab_body, premium_badge_name)
}
None => not_found_state("User", user_id, None, None),
};
let title = user
@@ -79,6 +93,7 @@ fn render_user_detail(
user: &AdminUser,
active_tab: &str,
tab_body: Option<Markup>,
premium_badge_name: Option<&str>,
) -> Markup {
let display_name = user
.global_name
@@ -143,6 +158,7 @@ fn render_user_detail(
user.premium_type,
user.premium_since.as_deref(),
config.self_hosted,
premium_badge_name,
false,
))
}
@@ -33,7 +33,12 @@ fn status_badge(user: &AdminUser) -> Markup {
}
}
pub fn user_peek_fragment(config: &AdminConfig, user: &AdminUser, admin_acls: &[String]) -> Markup {
pub fn user_peek_fragment(
config: &AdminConfig,
user: &AdminUser,
admin_acls: &[String],
premium_badge_name: Option<&str>,
) -> Markup {
let base = &config.base_path;
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL);
let display = user
@@ -62,6 +67,7 @@ pub fn user_peek_fragment(config: &AdminConfig, user: &AdminUser, admin_acls: &[
user.premium_type,
user.premium_since.as_deref(),
config.self_hosted,
premium_badge_name,
true,
))
}
+19 -3
View File
@@ -95,6 +95,7 @@ impl UserListParams {
}
}
#[allow(clippy::too_many_arguments)]
pub fn users_list_page(
config: &AdminConfig,
auth: &AuthContext,
@@ -102,10 +103,18 @@ pub fn users_list_page(
results: Option<&[AdminUser]>,
has_more: bool,
can_view_email: bool,
premium_badge_name: Option<&str>,
is_htmx: bool,
) -> Markup {
let base = &config.base_path;
let results_markup = render_results(config, params, results, has_more, can_view_email);
let results_markup = render_results(
config,
params,
results,
has_more,
can_view_email,
premium_badge_name,
);
if is_htmx {
return results_markup;
@@ -215,6 +224,7 @@ fn render_results(
results: Option<&[AdminUser]>,
page_has_more: bool,
can_view_email: bool,
premium_badge_name: Option<&str>,
) -> Markup {
let base = &config.base_path;
html! {
@@ -236,7 +246,7 @@ fn render_results(
"Copy IDs"
}
}
(render_users_table(config, users, can_view_email))
(render_users_table(config, users, can_view_email, premium_badge_name))
script { (maud::PreEscaped(copy_ids_script())) }
@if !params.has_id_lookup() && (params.page > 0 || page_has_more) {
(pagination_controls(base, params, page_has_more))
@@ -301,7 +311,12 @@ fn user_status_badge(user: &AdminUser) -> Markup {
}
}
fn render_users_table(config: &AdminConfig, users: &[AdminUser], can_view_email: bool) -> Markup {
fn render_users_table(
config: &AdminConfig,
users: &[AdminUser],
can_view_email: bool,
premium_badge_name: Option<&str>,
) -> Markup {
let base = &config.base_path;
table_container(html! {
table class="min-w-full divide-y divide-neutral-200" {
@@ -343,6 +358,7 @@ fn render_users_table(config: &AdminConfig, users: &[AdminUser], can_view_email:
user.premium_type,
user.premium_since.as_deref(),
config.self_hosted,
premium_badge_name,
true,
))
}
+4
View File
@@ -55,6 +55,10 @@ impl MultiValueForm {
self.fields.contains_key(key)
}
pub fn values(&self, key: &str) -> &[String] {
self.fields.get(key).map(Vec::as_slice).unwrap_or_default()
}
pub fn first(&self, key: &str) -> Option<&str> {
self.fields
.get(key)
+62 -1
View File
@@ -471,7 +471,9 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"wordmark_url": "https://cdn.example.com/wordmark.svg",
"favicon_url": "https://cdn.example.com/favicon.ico",
"theme_color": "#5865f2",
"future_asset_url": "https://cdn.example.com/future.png"
"future_asset_url": "https://cdn.example.com/future.png",
"premium_product_name": "Gold",
"premium_info_url": "https://example.com/gold"
},
"setup": {"configured": true},
"legal": {
@@ -569,6 +571,36 @@ fn deserialize_instance_config_response_with_unknown_keys() {
}
}
},
"billing": {
"enabled": true,
"effective_enabled": true,
"stripe_secret_key_set": true,
"stripe_webhook_secret_set": false,
"stripe_secret_key_stored": true,
"stripe_webhook_secret_stored": false,
"automatic_tax": null,
"tax_id_collection": true,
"terms_consent_required": false,
"effective_automatic_tax": false,
"effective_tax_id_collection": true,
"effective_terms_consent_required": false,
"default_currency": "GBP",
"prices": {
"GBP": {
"monthly": "price_1GbpM",
"yearly": "price_1GbpY",
"gift_1_month": null,
"gift_1_year": "price_1GbpG"
}
},
"country_currencies": {"GB": "GBP"},
"legacy_prices": {"monthly_GBP": ["price_1OldA"]},
"billing_active": true,
"stripe_serviceable": true,
"catalog_mode": "operator",
"webhook_url": "https://api.example.com/stripe/webhook",
"future_billing_knob": 1
},
"future_section": {"enabled": true, "rollout_basis_points": 10000},
"future_flag": 3
}"##;
@@ -606,8 +638,36 @@ fn deserialize_instance_config_response_with_unknown_keys() {
assert!(resp.policy.single_community_guild_id.is_none());
assert_eq!(resp.policy.services.gif_enabled, Some(true));
assert_eq!(resp.app_public.branding.product_name, "Fluxer");
assert_eq!(resp.app_public.branding.premium_product_name, "Gold");
assert!(resp.billing.billing_active);
assert!(resp.media.attachment_decay.effective.enabled);
let ours: types::InstanceConfigResponse =
serde_json::from_str(json).expect("hand-written instance config");
assert_eq!(ours.app_public.branding.premium_product_name, "Gold");
assert!(ours.billing.stripe_secret_key_stored);
assert_eq!(ours.billing.tax_id_collection, Some(true));
assert!(ours.billing.effective_tax_id_collection);
assert_eq!(
ours.app_public.branding.premium_info_url.as_deref(),
Some("https://example.com/gold")
);
assert!(ours.billing.billing_active);
assert!(ours.billing.stripe_serviceable);
assert!(!ours.billing.stripe_webhook_secret_set);
assert_eq!(
ours.billing.catalog_mode,
types::BillingCatalogMode::Operator
);
assert_eq!(ours.billing.default_currency.as_deref(), Some("GBP"));
let gbp = &ours.billing.prices.as_ref().expect("prices")["GBP"];
assert_eq!(gbp.gift_1_year.as_deref(), Some("price_1GbpG"));
assert_eq!(gbp.gift_1_month, None);
assert_eq!(
ours.billing.legacy_prices.as_ref().expect("legacy")["monthly_GBP"],
vec!["price_1OldA".to_owned()]
);
let without_unknown_keys = json
.replace("\"future_rollout_knob\": 3,", "")
.replace("\"future_presentation_knob\": \"verbose\",", "")
@@ -621,6 +681,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
)
.replace("\"future_service_enabled\": true,", "")
.replace("\"future_curve\": 1.5,", "")
.replace(",\n \"future_billing_knob\": 1", "")
.replace(
"\"future_section\": {\"enabled\": true, \"rollout_basis_points\": 10000},",
"",