mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c488906131 | ||
|
|
39c72f0fb0 | ||
|
|
3736d94d73 | ||
|
|
192cec689a | ||
|
|
e895c41bf0 | ||
|
|
997d98c65c | ||
|
|
c9ae5b6ee8 | ||
|
|
a728be4062 | ||
|
|
fce81367fb | ||
|
|
5a4edc0b59 | ||
|
|
713ae5f7f5 |
+128
-130
@@ -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,
|
||||
@@ -10523,7 +10523,6 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
|
||||
"push_relay": {"$ref": "#/components/schemas/PushRelayConfigResponse"},
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
|
||||
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaConfigResponse"},
|
||||
@@ -10639,7 +10638,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 +10651,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,12 +10952,12 @@
|
||||
},
|
||||
"required": ["attachment_decay"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"billing": {"$ref": "#/components/schemas/InstanceBillingResponse"}
|
||||
},
|
||||
"required": [
|
||||
"sso",
|
||||
"gateway_rollout",
|
||||
"voice_noise_suppression",
|
||||
"push_relay",
|
||||
"domain_migration",
|
||||
"altcha_captcha",
|
||||
@@ -10965,7 +10968,8 @@
|
||||
"app_public",
|
||||
"policy",
|
||||
"integrations",
|
||||
"media"
|
||||
"media",
|
||||
"billing"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -11089,10 +11093,6 @@
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/GatewayRolloutConfigUpdateRequest"}]
|
||||
},
|
||||
"voice_noise_suppression": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
|
||||
},
|
||||
"push_relay": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/PushRelayConfigUpdateRequest"}]},
|
||||
"domain_migration": {
|
||||
"nullable": true,
|
||||
@@ -11153,7 +11153,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 +11323,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"billing": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/InstanceBillingUpdateRequest"}]}
|
||||
}
|
||||
},
|
||||
"ListGuildStickersResponse": {
|
||||
@@ -15180,6 +15183,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",
|
||||
@@ -15277,50 +15302,6 @@
|
||||
}
|
||||
},
|
||||
"PushRelayConfigUpdateRequest": {"type": "object", "properties": {"relay_consent_accepted": {"type": "boolean"}}},
|
||||
"VoiceNoiseSuppressionConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"default_backend": {"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}]},
|
||||
"enabled_backends": {
|
||||
"maxItems": 7,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
|
||||
},
|
||||
"allow_user_override": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"type": "boolean"},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"guild_overrides": {
|
||||
"maxItems": 200,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"guild_id": {"type": "string", "pattern": "^\\d{1,20}$"},
|
||||
"backend": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
|
||||
},
|
||||
"required": ["guild_id", "backend"]
|
||||
}
|
||||
},
|
||||
"suppression_strength": {"type": "integer", "minimum": 0, "maximum": 100}
|
||||
}
|
||||
},
|
||||
"GatewayRolloutConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15335,9 +15316,84 @@
|
||||
"voice_e2ee_scope": {"type": "string", "enum": ["guild_feature_only", "platform_wide"]}
|
||||
}
|
||||
},
|
||||
"VoiceNoiseSuppressionBackendSchema": {
|
||||
"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",
|
||||
@@ -15509,76 +15565,6 @@
|
||||
"required": ["relay_consent_accepted", "relay_consent_accepted_at", "relay_consent_accepted_by"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VoiceNoiseSuppressionConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"default_backend": {
|
||||
"default": "standard",
|
||||
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}]
|
||||
},
|
||||
"enabled_backends": {
|
||||
"default": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"],
|
||||
"maxItems": 7,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
|
||||
},
|
||||
"allow_user_override": {"default": true, "type": "boolean"},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"default": "voice-ns-v1", "type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"default": false, "type": "boolean"},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"guild_overrides": {
|
||||
"default": [],
|
||||
"maxItems": 200,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"guild_id": {"type": "string", "pattern": "^\\d{1,20}$"},
|
||||
"backend": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
|
||||
},
|
||||
"required": ["guild_id", "backend"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"suppression_strength": {"default": 80, "type": "integer", "minimum": 0, "maximum": 100}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"default_backend",
|
||||
"enabled_backends",
|
||||
"allow_user_override",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"included_guild_ids",
|
||||
"include_premium_users",
|
||||
"excluded_user_ids",
|
||||
"guild_overrides",
|
||||
"suppression_strength"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"GatewayRolloutConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15609,6 +15595,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": {
|
||||
|
||||
@@ -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,7 +2,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use crate::api::generated::types::VoiceNoiseSuppressionBackendSchema as NoiseSuppressionBackend;
|
||||
use super::{InstanceBillingResponse, InstanceBillingUpdateRequest};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct InstanceConfigResponse {
|
||||
@@ -21,8 +21,6 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub media: InstanceMediaResponse,
|
||||
#[serde(default)]
|
||||
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
|
||||
#[serde(default)]
|
||||
pub push_relay: PushRelayConfigResponse,
|
||||
#[serde(default)]
|
||||
pub domain_migration: DomainMigrationConfigResponse,
|
||||
@@ -32,6 +30,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 +336,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 +353,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 +363,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)]
|
||||
@@ -460,103 +469,6 @@ pub const ALTCHA_CAPTCHA_DEFAULT_SALT: &str = "altcha-captcha-v1";
|
||||
pub const ALTCHA_CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=100_000;
|
||||
pub const ALTCHA_CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=1_000_000;
|
||||
pub const PROFILE_TIMEZONE_DEFAULT_SALT: &str = "profile-timezone-v1";
|
||||
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
|
||||
|
||||
impl NoiseSuppressionBackend {
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::None,
|
||||
Self::Standard,
|
||||
Self::Gate,
|
||||
Self::Speex,
|
||||
Self::Rnnoise,
|
||||
Self::Gtcrn,
|
||||
Self::DeepFilter,
|
||||
];
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "None (pass-through)",
|
||||
Self::Standard => "Standard (WebRTC)",
|
||||
Self::Gate => "Noise gate",
|
||||
Self::Speex => "Speex",
|
||||
Self::Rnnoise => "RNNoise",
|
||||
Self::Gtcrn => "GTCRN",
|
||||
Self::DeepFilter => "DeepFilterNet",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct VoiceNoiseSuppressionGuildOverride {
|
||||
pub guild_id: String,
|
||||
pub backend: NoiseSuppressionBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct VoiceNoiseSuppressionConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub default_backend: NoiseSuppressionBackend,
|
||||
pub enabled_backends: Vec<NoiseSuppressionBackend>,
|
||||
pub allow_user_override: bool,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub included_guild_ids: Vec<String>,
|
||||
pub include_premium_users: bool,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
|
||||
pub suppression_strength: u32,
|
||||
}
|
||||
|
||||
impl Default for VoiceNoiseSuppressionConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
default_backend: NoiseSuppressionBackend::Standard,
|
||||
enabled_backends: NoiseSuppressionBackend::ALL.to_vec(),
|
||||
allow_user_override: true,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "voice-ns-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
include_premium_users: false,
|
||||
excluded_user_ids: Vec::new(),
|
||||
guild_overrides: Vec::new(),
|
||||
suppression_strength: 80,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_backend: Option<NoiseSuppressionBackend>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled_backends: Option<Vec<NoiseSuppressionBackend>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allow_user_override: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_guild_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_premium_users: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suppression_strength: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
@@ -844,8 +756,6 @@ pub struct InstanceConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub media: Option<InstanceMediaUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub push_relay: Option<PushRelayConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
|
||||
@@ -855,6 +765,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 +975,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)]
|
||||
@@ -1167,30 +1083,11 @@ mod tests {
|
||||
use crate::api::generated::types as generated_types;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn noise_suppression_backend_choices_use_the_generated_wire_contract() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(NoiseSuppressionBackend::ALL).expect("serializable backends"),
|
||||
json!([
|
||||
"none",
|
||||
"standard",
|
||||
"gate",
|
||||
"speex",
|
||||
"rnnoise",
|
||||
"gtcrn",
|
||||
"deep_filter"
|
||||
])
|
||||
);
|
||||
assert!(serde_json::from_value::<NoiseSuppressionBackend>(json!("deepfilter")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_instance_experiment_config_matches_the_published_contract() {
|
||||
let schema: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../../openapi-admin.json"))
|
||||
.expect("admin schema");
|
||||
let noise = serde_json::from_value::<VoiceNoiseSuppressionConfigResponse>(json!({}))
|
||||
.expect("default noise config");
|
||||
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
|
||||
.expect("default domain migration config");
|
||||
let altcha_captcha = serde_json::from_value::<AltchaCaptchaConfigResponse>(json!({}))
|
||||
@@ -1199,7 +1096,6 @@ mod tests {
|
||||
.expect("default profile timezone config");
|
||||
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
|
||||
.expect("default delivery config");
|
||||
let noise = serde_json::to_value(noise).expect("serializable noise config");
|
||||
let domain_migration =
|
||||
serde_json::to_value(domain_migration).expect("serializable domain migration config");
|
||||
let altcha_captcha =
|
||||
@@ -1207,8 +1103,6 @@ mod tests {
|
||||
let profile_timezone =
|
||||
serde_json::to_value(profile_timezone).expect("serializable profile timezone config");
|
||||
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
|
||||
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
|
||||
serde_json::from_value(noise.clone()).expect("generated noise config contract");
|
||||
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
|
||||
serde_json::from_value(domain_migration.clone())
|
||||
.expect("generated domain migration config contract");
|
||||
@@ -1220,10 +1114,6 @@ mod tests {
|
||||
.expect("generated profile timezone config contract");
|
||||
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
|
||||
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
|
||||
noise
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_domain_migration)
|
||||
.expect("serializable generated domain migration config"),
|
||||
@@ -1245,7 +1135,6 @@ mod tests {
|
||||
delivery
|
||||
);
|
||||
for (name, value) in [
|
||||
("VoiceNoiseSuppressionConfigResponse", noise),
|
||||
("DomainMigrationConfigResponse", domain_migration),
|
||||
("AltchaCaptchaConfigResponse", altcha_captcha),
|
||||
("ProfileTimezoneConfigResponse", profile_timezone),
|
||||
@@ -1260,31 +1149,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
enabled_backends: Some(Vec::new()),
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
guild_overrides: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::VoiceNoiseSuppressionConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"enabled_backends": [], "included_user_ids": [], "excluded_user_ids": [], "guild_overrides": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(VoiceNoiseSuppressionConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_migration_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = DomainMigrationConfigUpdateRequest {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,10 +19,9 @@ use crate::{
|
||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend,
|
||||
PremiumMode, ProfileTimezoneConfigUpdateRequest, PushRelayConfigUpdateRequest,
|
||||
RegistrationMode, SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
|
||||
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, PremiumMode,
|
||||
ProfileTimezoneConfigUpdateRequest, PushRelayConfigUpdateRequest, RegistrationMode,
|
||||
SsoConfigUpdateRequest, VoiceE2eeScope,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -195,7 +194,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);
|
||||
@@ -205,8 +206,12 @@ pub async fn instance_config_post(
|
||||
let update = build_media_update(&form);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
}
|
||||
"update_voice_noise_suppression" => match build_voice_noise_suppression_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
"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" => {
|
||||
@@ -352,6 +357,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,
|
||||
@@ -466,7 +482,6 @@ fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRe
|
||||
}
|
||||
|
||||
const EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
|
||||
const VOICE_NS_SUPPRESSION_STRENGTH_MAX: u32 = 100;
|
||||
const EXPERIMENT_MAX_ROLLOUT_SALT_CHARS: usize = 64;
|
||||
const EXPERIMENT_MAX_SNOWFLAKE_LENGTH: usize = 20;
|
||||
const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS: u64 = 60;
|
||||
@@ -507,22 +522,13 @@ fn parse_experiment_rollout_salt(
|
||||
"Rollout salt must be between 1 and {EXPERIMENT_MAX_ROLLOUT_SALT_CHARS} characters"
|
||||
));
|
||||
}
|
||||
Ok(Some(salt.to_owned()))
|
||||
}
|
||||
|
||||
fn parse_ascii_experiment_rollout_salt(
|
||||
form: &MultiValueForm,
|
||||
key: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let salt = parse_experiment_rollout_salt(form, key)?;
|
||||
if let Some(value) = salt.as_deref()
|
||||
&& !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_graphic() || byte == b' ')
|
||||
if !salt
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_graphic() || byte == b' ')
|
||||
{
|
||||
return Err("Rollout salt must use printable ASCII".to_owned());
|
||||
}
|
||||
Ok(salt)
|
||||
Ok(Some(salt.to_owned()))
|
||||
}
|
||||
|
||||
fn is_experiment_snowflake(value: &str) -> bool {
|
||||
@@ -557,117 +563,6 @@ fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, St
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_guild_overrides(
|
||||
value: &str,
|
||||
) -> Result<Vec<VoiceNoiseSuppressionGuildOverride>, String> {
|
||||
let mut overrides: Vec<VoiceNoiseSuppressionGuildOverride> = Vec::new();
|
||||
for (index, line) in value.lines().enumerate() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let line_number = index + 1;
|
||||
let (guild_id, backend) = line.split_once('=').ok_or_else(|| {
|
||||
format!("Guild overrides line {line_number} must use guild_id=backend")
|
||||
})?;
|
||||
let guild_id = guild_id.trim();
|
||||
if !is_experiment_snowflake(guild_id) {
|
||||
return Err(format!(
|
||||
"Guild overrides line {line_number} must use a guild ID with 1 to 20 decimal digits"
|
||||
));
|
||||
}
|
||||
let backend = backend.trim().parse().map_err(|_| {
|
||||
format!("Guild overrides line {line_number} must name a supported backend")
|
||||
})?;
|
||||
if let Some(existing) = overrides
|
||||
.iter()
|
||||
.find(|existing| existing.guild_id == guild_id)
|
||||
{
|
||||
if existing.backend != backend {
|
||||
return Err(format!(
|
||||
"Guild overrides line {line_number} conflicts with an earlier rule for guild {guild_id}"
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if overrides.len() == VOICE_NS_MAX_GUILD_OVERRIDES {
|
||||
return Err(format!(
|
||||
"Guild overrides must contain at most {VOICE_NS_MAX_GUILD_OVERRIDES} unique guilds"
|
||||
));
|
||||
}
|
||||
overrides.push(VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: guild_id.to_owned(),
|
||||
backend,
|
||||
});
|
||||
}
|
||||
Ok(overrides)
|
||||
}
|
||||
|
||||
fn build_voice_noise_suppression_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
let selected: Vec<NoiseSuppressionBackend> = form
|
||||
.list_values_any(&["voice_ns_enabled_backends[]", "voice_ns_enabled_backends"])
|
||||
.into_iter()
|
||||
.map(|value| {
|
||||
value.parse().map_err(|_| {
|
||||
"Enabled backends must name supported noise suppression backends".to_owned()
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
let enabled_backends = NoiseSuppressionBackend::ALL
|
||||
.into_iter()
|
||||
.filter(|backend| selected.contains(backend))
|
||||
.collect();
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
voice_noise_suppression: Some(VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("voice_ns_enabled")),
|
||||
default_backend: form
|
||||
.first("voice_ns_default_backend")
|
||||
.map(|value| {
|
||||
value.parse().map_err(|_| {
|
||||
"Default backend must name a supported noise suppression backend".to_owned()
|
||||
})
|
||||
})
|
||||
.transpose()?,
|
||||
enabled_backends: Some(enabled_backends),
|
||||
allow_user_override: Some(form.bool_value("voice_ns_allow_user_override")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"voice_ns_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "voice_ns_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_ns_included_user_ids").unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
included_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_ns_included_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included guild IDs",
|
||||
)?),
|
||||
include_premium_users: Some(form.bool_value("voice_ns_include_premium_users")),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
guild_overrides: Some(parse_voice_noise_suppression_guild_overrides(
|
||||
form.first("voice_ns_guild_overrides").unwrap_or_default(),
|
||||
)?),
|
||||
suppression_strength: parse_form_number(
|
||||
form,
|
||||
"voice_ns_suppression_strength",
|
||||
"Suppression strength",
|
||||
0,
|
||||
VOICE_NS_SUPPRESSION_STRENGTH_MAX,
|
||||
)?,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_push_relay_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
InstanceConfigUpdateRequest {
|
||||
push_relay: Some(PushRelayConfigUpdateRequest {
|
||||
@@ -690,10 +585,7 @@ fn build_domain_migration_update(
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_ascii_experiment_rollout_salt(
|
||||
form,
|
||||
"domain_migration_rollout_salt",
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "domain_migration_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("domain_migration_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
@@ -736,7 +628,7 @@ fn build_altcha_captcha_update(
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_ascii_experiment_rollout_salt(form, "altcha_captcha_rollout_salt")?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "altcha_captcha_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("altcha_captcha_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
@@ -786,10 +678,7 @@ fn build_profile_timezone_update(
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_ascii_experiment_rollout_salt(
|
||||
form,
|
||||
"profile_timezone_rollout_salt",
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "profile_timezone_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("profile_timezone_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
@@ -867,6 +756,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")),
|
||||
@@ -1417,77 +1307,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_collects_backends_and_validates_numbers() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"voice_ns_enabled=true&voice_ns_allow_user_override=on&voice_ns_default_backend=rnnoise&voice_ns_enabled_backends%5B%5D=deep_filter&voice_ns_enabled_backends%5B%5D=none&voice_ns_enabled_backends%5B%5D=none&voice_ns_rollout_basis_points=10000&voice_ns_suppression_strength=100&voice_ns_rollout_salt=%20voice-ns-v2%20",
|
||||
);
|
||||
let request = build_voice_noise_suppression_update(&form).expect("valid form");
|
||||
let update = request
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.allow_user_override, Some(true));
|
||||
assert_eq!(
|
||||
update.default_backend,
|
||||
Some(NoiseSuppressionBackend::Rnnoise)
|
||||
);
|
||||
assert_eq!(
|
||||
update.enabled_backends,
|
||||
Some(vec![
|
||||
NoiseSuppressionBackend::None,
|
||||
NoiseSuppressionBackend::DeepFilter
|
||||
])
|
||||
);
|
||||
assert_eq!(update.rollout_basis_points, Some(10_000));
|
||||
assert_eq!(update.suppression_strength, Some(100));
|
||||
assert_eq!(update.rollout_salt, Some("voice-ns-v2".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_voice_noise_suppression_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"voice_noise_suppression": {
|
||||
"enabled": false,
|
||||
"allow_user_override": false,
|
||||
"enabled_backends": [],
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_reads_user_id_textareas() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"voice_ns_included_user_ids=1500000000000000001%0A1500000000000000002&voice_ns_excluded_user_ids=1500000000000000003%2C%201500000000000000004",
|
||||
);
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.expect("valid form")
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec![
|
||||
"1500000000000000001".to_owned(),
|
||||
"1500000000000000002".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec![
|
||||
"1500000000000000003".to_owned(),
|
||||
"1500000000000000004".to_owned()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_user_ids_splits_newlines_and_commas() {
|
||||
assert_eq!(
|
||||
@@ -1547,193 +1366,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_guild_overrides_rejects_malformed_lines() {
|
||||
for (line, message) in [
|
||||
("456", "Guild overrides line 3 must use guild_id=backend"),
|
||||
(
|
||||
"=gate",
|
||||
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"not-a-guild=gate",
|
||||
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"999999999999999999999=gate",
|
||||
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"456=unknown_backend",
|
||||
"Guild overrides line 3 must name a supported backend",
|
||||
),
|
||||
(
|
||||
"456=",
|
||||
"Guild overrides line 3 must name a supported backend",
|
||||
),
|
||||
(
|
||||
"123=gate",
|
||||
"Guild overrides line 3 conflicts with an earlier rule for guild 123",
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_guild_overrides(&format!("\n123=rnnoise\n{line}"))
|
||||
.expect_err("invalid guild rule"),
|
||||
message,
|
||||
"{line}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_rejects_invalid_numbers() {
|
||||
for (key, message, above_max) in [
|
||||
(
|
||||
"voice_ns_rollout_basis_points",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
"10001",
|
||||
),
|
||||
(
|
||||
"voice_ns_suppression_strength",
|
||||
"Suppression strength must be a whole number between 0 and 100",
|
||||
"101",
|
||||
),
|
||||
] {
|
||||
for value in [
|
||||
"",
|
||||
"%20%20",
|
||||
"abc",
|
||||
"-1",
|
||||
"1.5",
|
||||
"9999999999999999999999999",
|
||||
above_max,
|
||||
] {
|
||||
let form = MultiValueForm::parse(format!("{key}={value}").as_bytes());
|
||||
assert_eq!(
|
||||
build_voice_noise_suppression_update(&form).expect_err("invalid number"),
|
||||
message,
|
||||
"{key}={value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_accepts_padded_numbers() {
|
||||
let form = MultiValueForm::parse(b"voice_ns_rollout_basis_points=%20250%20");
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.expect("valid form")
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.rollout_basis_points, Some(250));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_rejects_invalid_rollout_salts() {
|
||||
for salt in [
|
||||
String::new(),
|
||||
" ".to_owned(),
|
||||
"é".repeat(65),
|
||||
"🎲".repeat(33),
|
||||
] {
|
||||
let form = MultiValueForm::parse(format!("voice_ns_rollout_salt={salt}").as_bytes());
|
||||
assert_eq!(
|
||||
build_voice_noise_suppression_update(&form).expect_err("invalid salt"),
|
||||
"Rollout salt must be between 1 and 64 characters"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_preserves_valid_rollout_salts() {
|
||||
for salt in ["x".to_owned(), "é".repeat(64), "🎲".repeat(32)] {
|
||||
let form =
|
||||
MultiValueForm::parse(format!("voice_ns_rollout_salt=%20{salt}%20").as_bytes());
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.expect("valid form")
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.rollout_salt, Some(salt));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_guild_overrides_normalizes_identical_rules() {
|
||||
let overrides = parse_voice_noise_suppression_guild_overrides(
|
||||
" 1600000000000000001 = rnnoise \n\n1600000000000000001=rnnoise\n1600000000000000002=speex\n",
|
||||
).expect("valid guild rules");
|
||||
assert_eq!(
|
||||
overrides,
|
||||
vec![
|
||||
VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000001".to_owned(),
|
||||
backend: NoiseSuppressionBackend::Rnnoise,
|
||||
},
|
||||
VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000002".to_owned(),
|
||||
backend: NoiseSuppressionBackend::Speex,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_guild_overrides_rejects_exceeding_the_cap() {
|
||||
let value = (0..VOICE_NS_MAX_GUILD_OVERRIDES)
|
||||
.map(|index| format!("{index}=gate"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let overrides =
|
||||
parse_voice_noise_suppression_guild_overrides(&format!("{value}\n199=gate"))
|
||||
.expect("valid guild rules at cap");
|
||||
assert_eq!(overrides.len(), VOICE_NS_MAX_GUILD_OVERRIDES);
|
||||
assert_eq!(
|
||||
overrides.last().map(|entry| entry.guild_id.as_str()),
|
||||
Some("199")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_guild_overrides(&format!("{value}\n200=gate"))
|
||||
.expect_err("too many guild rules"),
|
||||
"Guild overrides must contain at most 200 unique guilds"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_reports_invalid_targeting_fields() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"voice_ns_default_backend=unknown",
|
||||
"Default backend must name a supported noise suppression backend",
|
||||
),
|
||||
(
|
||||
"voice_ns_default_backend=",
|
||||
"Default backend must name a supported noise suppression backend",
|
||||
),
|
||||
(
|
||||
"voice_ns_enabled_backends%5B%5D=rnnoise&voice_ns_enabled_backends%5B%5D=unknown",
|
||||
"Enabled backends must name supported noise suppression backends",
|
||||
),
|
||||
(
|
||||
"voice_ns_included_user_ids=123%2Cinvalid",
|
||||
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"voice_ns_excluded_user_ids=123%2Cinvalid",
|
||||
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"voice_ns_guild_overrides=123%3Dgate%0A123%3Drnnoise",
|
||||
"Guild overrides line 2 conflicts with an earlier rule for guild 123",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_voice_noise_suppression_update(&form).expect_err("invalid targeting"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_domain_migration_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
@@ -1800,6 +1432,10 @@ mod tests {
|
||||
"domain_migration_rollout_salt=%20%20",
|
||||
"Rollout salt must be between 1 and 64 characters",
|
||||
),
|
||||
(
|
||||
format!("domain_migration_rollout_salt={}", "x".repeat(65)).as_str(),
|
||||
"Rollout salt must be between 1 and 64 characters",
|
||||
),
|
||||
(
|
||||
"domain_migration_rollout_salt=caf%C3%A9",
|
||||
"Rollout salt must use printable ASCII",
|
||||
@@ -1954,11 +1590,10 @@ mod tests {
|
||||
fn every_experiment_update_rejects_an_invalid_included_guild_id() {
|
||||
for (prefix, build) in [
|
||||
(
|
||||
"voice_ns",
|
||||
build_voice_noise_suppression_update
|
||||
"domain_migration",
|
||||
build_domain_migration_update
|
||||
as fn(&MultiValueForm) -> Result<InstanceConfigUpdateRequest, String>,
|
||||
),
|
||||
("domain_migration", build_domain_migration_update),
|
||||
("altcha_captcha", build_altcha_captcha_update),
|
||||
("profile_timezone", build_profile_timezone_update),
|
||||
] {
|
||||
|
||||
@@ -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, ¶ms.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, ¶ms.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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
)],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,9 @@ use crate::{
|
||||
DomainMigrationConfigResponse, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
||||
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, NoiseSuppressionBackend,
|
||||
PROFILE_TIMEZONE_DEFAULT_SALT, PendingRegistrationResponse, ProfileTimezoneConfigResponse,
|
||||
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse,
|
||||
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, PROFILE_TIMEZONE_DEFAULT_SALT,
|
||||
PendingRegistrationResponse, ProfileTimezoneConfigResponse, PushRelayConfigResponse,
|
||||
RegistrationUrlResponse, SsoConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -26,6 +25,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 +127,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,
|
||||
))
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -157,7 +175,6 @@ pub fn instance_config_page(
|
||||
"Gateway rollout behavior and the limit rules applied to users and guilds.",
|
||||
html! {
|
||||
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
|
||||
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
|
||||
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
|
||||
(altcha_captcha_section(base, csrf_token, &instance_config.altcha_captcha))
|
||||
(profile_timezone_section(base, csrf_token, &instance_config.profile_timezone))
|
||||
@@ -209,7 +226,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 +241,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 +385,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 +400,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"))
|
||||
@@ -993,234 +1022,6 @@ fn gateway_rollout_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn voice_noise_suppression_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if voice_noise_suppression.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let backend_labels =
|
||||
NoiseSuppressionBackend::ALL.map(|backend| (backend.to_string(), backend.label()));
|
||||
let backend_options = backend_labels
|
||||
.iter()
|
||||
.map(|(value, label)| (value.as_str(), *label))
|
||||
.collect::<Vec<_>>();
|
||||
let included_user_ids = voice_noise_suppression.included_user_ids.join("\n");
|
||||
let excluded_user_ids = voice_noise_suppression.excluded_user_ids.join("\n");
|
||||
let guild_overrides = voice_noise_suppression
|
||||
.guild_overrides
|
||||
.iter()
|
||||
.map(|entry| format!("{}={}", entry.guild_id, entry.backend))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
section_card_with_description(
|
||||
"Voice Noise Suppression",
|
||||
"Pick which noise suppression backend targeted clients load in voice calls, and how many \
|
||||
of them are targeted. While the master switch below is off nothing on this form reaches \
|
||||
any client: every user keeps the audio pipeline they have today, whatever the rest of \
|
||||
these fields say.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_voice_noise_suppression"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (voice_noise_suppression.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"voice_ns_enabled",
|
||||
"true",
|
||||
"Serve noise suppression assignments to clients",
|
||||
voice_noise_suppression.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state. With this unchecked every client is told the \
|
||||
feature is inert and keeps its current behavior, so the rollout, targeting \
|
||||
and override fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Backends" }
|
||||
(select_input(
|
||||
"voice_ns_default_backend",
|
||||
"Default Backend",
|
||||
&backend_options,
|
||||
&voice_noise_suppression.default_backend.to_string(),
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"The backend assigned by always-on user rules and the canary. A default \
|
||||
that is not ticked below is unavailable, but per-guild overrides can \
|
||||
still target users."
|
||||
}
|
||||
div class="grid grid-cols-1 gap-2 sm:grid-cols-2" {
|
||||
@for backend in NoiseSuppressionBackend::ALL {
|
||||
(checkbox(
|
||||
"voice_ns_enabled_backends[]",
|
||||
&backend.to_string(),
|
||||
backend.label(),
|
||||
voice_noise_suppression.enabled_backends.contains(&backend),
|
||||
true,
|
||||
))
|
||||
}
|
||||
}
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Backends clients are allowed to load. Unticking one withdraws it from \
|
||||
every user, including anyone who picked it themselves."
|
||||
}
|
||||
(checkbox(
|
||||
"voice_ns_allow_user_override",
|
||||
"true",
|
||||
"Let users pick their own backend from the ticked list",
|
||||
voice_noise_suppression.allow_user_override,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Applies only to users who are already targeted. It never pulls anyone \
|
||||
into the rollout."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"voice_ns_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&voice_noise_suppression.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"voice_ns_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&voice_noise_suppression.rollout_salt,
|
||||
"voice-ns-v1",
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above. Leave it alone to keep the current \
|
||||
cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_ns_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_noise_suppression.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save. Blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(checkbox(
|
||||
"voice_ns_include_premium_users",
|
||||
"true",
|
||||
"Include premium users",
|
||||
voice_noise_suppression.include_premium_users,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Includes every account with active premium perks, regardless of the \
|
||||
percentage above. The never-on list still wins."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_ns_included_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1500000000000000005\n1500000000000000006",
|
||||
&voice_noise_suppression.included_guild_ids.join("\n"),
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_noise_suppression.included_guild_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format, with guild IDs. Every member of a listed guild is \
|
||||
included regardless of the percentage above, unless the user is \
|
||||
in the never-on list."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_ns_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_noise_suppression.excluded_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage. This is the per-user kill switch."
|
||||
}
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Per-guild overrides" }
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_ns_guild_overrides",
|
||||
"Guild Overrides",
|
||||
"1600000000000000001=rnnoise\n1600000000000000002=deep_filter",
|
||||
&guild_overrides,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_noise_suppression.guild_overrides.len(),
|
||||
VOICE_NS_MAX_GUILD_OVERRIDES,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One per line as guild_id=backend. A guild \
|
||||
rule targets callers even outside the canary. Always-on user rules \
|
||||
take precedence, and excluded users stay off. Invalid lines and \
|
||||
conflicting rules for the same guild prevent the save. \
|
||||
Unticked backends stay stored but are inactive."
|
||||
}
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Processing" }
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(number_field(
|
||||
"voice_ns_suppression_strength",
|
||||
"Suppression Strength",
|
||||
&voice_noise_suppression.suppression_strength.to_string(),
|
||||
Some(0), Some(100), "1",
|
||||
Some("How aggressively the backend removes noise, 0 to 100. Higher values cut more background but chew more of the voice."),
|
||||
))
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Voice Noise Suppression Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn push_relay_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
@@ -2356,7 +2157,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."
|
||||
};
|
||||
@@ -2378,34 +2179,6 @@ fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Marku
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::types::VoiceNoiseSuppressionGuildOverride;
|
||||
|
||||
fn rendered_voice_noise_suppression_section(
|
||||
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
|
||||
) -> String {
|
||||
voice_noise_suppression_section("/admin", "csrf", voice_noise_suppression).into_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_noise_suppression_section_shows_list_counts_and_caps() {
|
||||
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
|
||||
included_user_ids: vec!["1500000000000000001".to_owned()],
|
||||
excluded_user_ids: vec![
|
||||
"1500000000000000002".to_owned(),
|
||||
"1500000000000000003".to_owned(),
|
||||
],
|
||||
guild_overrides: vec![VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000001".to_owned(),
|
||||
backend: NoiseSuppressionBackend::Rnnoise,
|
||||
}],
|
||||
..VoiceNoiseSuppressionConfigResponse::default()
|
||||
};
|
||||
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
|
||||
assert!(markup.contains("1 of 1000 stored"));
|
||||
assert!(markup.contains("2 of 1000 stored"));
|
||||
assert!(markup.contains("1 of 200 stored"));
|
||||
assert!(!markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_migration_section_shows_both_rollouts_and_list_counts() {
|
||||
@@ -2455,14 +2228,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
|
||||
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
|
||||
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 domain_migration_section_flags_a_list_at_its_cap() {
|
||||
let domain_migration = DomainMigrationConfigResponse {
|
||||
included_user_ids: (0..EXPERIMENT_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect(),
|
||||
..VoiceNoiseSuppressionConfigResponse::default()
|
||||
..DomainMigrationConfigResponse::default()
|
||||
};
|
||||
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
|
||||
let markup = domain_migration_section("/admin", "csrf", &domain_migration).into_string();
|
||||
assert!(markup.contains("1000 of 1000 stored"));
|
||||
assert!(markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -392,25 +392,6 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"voice_e2ee_scope": "guild_feature_only",
|
||||
"future_rollout_knob": 3
|
||||
},
|
||||
"voice_noise_suppression": {
|
||||
"enabled": true,
|
||||
"config_version": 4,
|
||||
"default_backend": "rnnoise",
|
||||
"enabled_backends": ["none", "standard", "rnnoise"],
|
||||
"allow_user_override": true,
|
||||
"rollout_basis_points": 10000,
|
||||
"rollout_salt": "voice-ns-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
"included_guild_ids": ["1500000000000000005"],
|
||||
"include_premium_users": true,
|
||||
"suppression_strength": 80,
|
||||
"future_presentation_knob": "verbose",
|
||||
"future_knob": 7,
|
||||
"future_object_knob": {"nested": true},
|
||||
"future_list_knob": ["a", "b"]
|
||||
},
|
||||
"push_relay": {
|
||||
"relay_consent_accepted": true,
|
||||
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
|
||||
@@ -426,6 +407,10 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"future_migration_knob": 9,
|
||||
"future_presentation_knob": "verbose",
|
||||
"future_knob": 7,
|
||||
"future_object_knob": {"nested": true},
|
||||
"future_list_knob": ["a", "b"],
|
||||
"anonymous_rollout_basis_points": 100,
|
||||
"standalone_forwarding": true
|
||||
},
|
||||
@@ -471,7 +456,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 +556,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
|
||||
}"##;
|
||||
@@ -578,11 +595,6 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
);
|
||||
|
||||
assert!(!resp.self_hosted);
|
||||
assert!(resp.voice_noise_suppression.enabled);
|
||||
assert_eq!(resp.voice_noise_suppression.config_version, 4);
|
||||
assert_eq!(resp.voice_noise_suppression.rollout_basis_points, 10000);
|
||||
assert_eq!(*resp.voice_noise_suppression.rollout_salt, "voice-ns-v1");
|
||||
assert_eq!(resp.voice_noise_suppression.enabled_backends.len(), 3);
|
||||
assert!(resp.domain_migration.enabled);
|
||||
assert_eq!(resp.domain_migration.config_version, 2);
|
||||
assert_eq!(resp.domain_migration.rollout_basis_points, 2500);
|
||||
@@ -601,13 +613,40 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
assert_eq!(resp.profile_timezone.included_user_ids.len(), 1);
|
||||
assert_eq!(resp.profile_timezone.included_guild_ids.len(), 1);
|
||||
assert!(resp.profile_timezone.include_premium_users);
|
||||
assert!(resp.voice_noise_suppression.include_premium_users);
|
||||
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
|
||||
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 +660,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},",
|
||||
"",
|
||||
|
||||
@@ -464,7 +464,6 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
|
||||
&[
|
||||
"/instance-config?action=update_gateway_rollout",
|
||||
"/instance-config?action=update_sso",
|
||||
"/instance-config?action=update_voice_noise_suppression",
|
||||
"/instance-config?action=update_domain_migration",
|
||||
"/instance-config?action=update_experiment_delivery",
|
||||
][..],
|
||||
@@ -1179,27 +1178,6 @@ fn instance_config() -> Value {
|
||||
"max_concurrent_guild_starts": 16,
|
||||
"voice_e2ee_scope": "guild_feature_only"
|
||||
},
|
||||
"voice_noise_suppression": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"default_backend": "standard",
|
||||
"enabled_backends": [
|
||||
"none",
|
||||
"standard",
|
||||
"gate",
|
||||
"speex",
|
||||
"rnnoise",
|
||||
"gtcrn",
|
||||
"deep_filter"
|
||||
],
|
||||
"allow_user_override": true,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "voice-ns-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
"suppression_strength": 80
|
||||
},
|
||||
"domain_migration": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
|
||||
@@ -12,7 +12,16 @@ import type {ValidationError} from '@fluxer/errors/src/domains/core/ValidationEr
|
||||
import {schemaMetadata} from '@fluxer/schema/src/SchemaMetadata';
|
||||
import type {Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets} from 'hono';
|
||||
import {getCookie} from 'hono/cookie';
|
||||
import {type core, type input, type output, ZodObject, ZodOptional, type ZodSafeParseResult, type ZodType} from 'zod';
|
||||
import {
|
||||
type core,
|
||||
type input,
|
||||
type output,
|
||||
ZodNullable,
|
||||
ZodObject,
|
||||
ZodOptional,
|
||||
type ZodSafeParseResult,
|
||||
type ZodType,
|
||||
} from 'zod';
|
||||
|
||||
initializeFluxerErrorMap();
|
||||
|
||||
@@ -46,8 +55,9 @@ function extractVariablesFromIssue(issue: core.$ZodIssue): Record<string, unknow
|
||||
}
|
||||
|
||||
function convertEmptyValuesToNull(obj: unknown, schema?: core.$ZodType, isRoot = true): unknown {
|
||||
while (schema instanceof ZodOptional) schema = schema.unwrap();
|
||||
if (schema && schemaMetadata.get(schema)?.preserveEmptyValues) return obj;
|
||||
while (schema instanceof ZodOptional || schema instanceof ZodNullable) schema = schema.unwrap();
|
||||
const metadata = schema ? schemaMetadata.get(schema) : undefined;
|
||||
if (metadata?.preserveEmptyValues) return obj;
|
||||
if (typeof obj === 'string' && obj === '') return null;
|
||||
if (Array.isArray(obj)) return obj.map((item) => convertEmptyValuesToNull(item, undefined, false));
|
||||
if (obj !== null && typeof obj === 'object') {
|
||||
@@ -59,7 +69,9 @@ function convertEmptyValuesToNull(obj: unknown, schema?: core.$ZodType, isRoot =
|
||||
convertEmptyValuesToNull(value, shape && Object.hasOwn(shape, key) ? shape[key] : undefined, false),
|
||||
]),
|
||||
);
|
||||
if (!isRoot && Object.values(processed).every((value) => value === null)) return null;
|
||||
if (!isRoot && !metadata?.preserveNullFields && Object.values(processed).every((value) => value === null)) {
|
||||
return null;
|
||||
}
|
||||
return processed;
|
||||
}
|
||||
return obj;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import {isPremiumTieringActive} from '@app/api/stripe/BillingConfigCache';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
@@ -25,14 +26,14 @@ export function CodesAdminController(app: HonoApp) {
|
||||
operationId: 'create_admin_gift_codes',
|
||||
summary: 'Issue gift codes',
|
||||
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.',
|
||||
'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.',
|
||||
responseSchema: CodesResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
if (Config.instance.selfHosted) {
|
||||
if (!isPremiumTieringActive()) {
|
||||
throw new FeatureNotAvailableSelfHostedError();
|
||||
}
|
||||
const adminService = ctx.get('adminService');
|
||||
|
||||
@@ -22,6 +22,7 @@ import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {InstancePolicyTransitionNotAllowedError} from '@fluxer/errors/src/domains/core/InstancePolicyTransitionNotAllowedError';
|
||||
import {
|
||||
BrandingAssetUploadRequest,
|
||||
@@ -39,7 +40,6 @@ import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/Doma
|
||||
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {ProfileTimezoneConfigSchema} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import type {InstanceBranding} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
|
||||
@@ -66,7 +66,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
const [
|
||||
ssoConfig,
|
||||
gatewayRollout,
|
||||
voiceNoiseSuppression,
|
||||
pushRelay,
|
||||
domainMigration,
|
||||
altchaCaptcha,
|
||||
@@ -78,7 +77,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
] = await Promise.all([
|
||||
instanceConfigRepository.getSsoConfig(),
|
||||
instanceConfigRepository.getGatewayRolloutConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getPushRelayConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getAltchaCaptchaConfig(),
|
||||
@@ -88,12 +86,13 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
instanceConfigRepository.getPendingRegistrations(),
|
||||
]);
|
||||
const [appPublic, policy, resolvedServices, integrations, media] = await Promise.all([
|
||||
const [appPublic, policy, resolvedServices, integrations, media, billing] = await Promise.all([
|
||||
instanceConfigRepository.getAppPublicConfig(),
|
||||
instanceConfigRepository.getInstancePolicyConfig(),
|
||||
instanceConfigRepository.getResolvedServicesConfig(),
|
||||
instanceConfigRepository.getInstanceIntegrationsAdminConfig(),
|
||||
instanceConfigRepository.getInstanceMediaAdminConfig(),
|
||||
instanceConfigRepository.getInstanceBillingAdminConfig(),
|
||||
]);
|
||||
return {
|
||||
sso: {
|
||||
@@ -113,7 +112,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
redirect_uri: deriveSsoRedirectUri(Config.endpoints.webApp),
|
||||
},
|
||||
gateway_rollout: gatewayRollout,
|
||||
voice_noise_suppression: voiceNoiseSuppression,
|
||||
push_relay: pushRelay,
|
||||
domain_migration: domainMigration,
|
||||
altcha_captcha: altchaCaptcha,
|
||||
@@ -151,6 +149,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
},
|
||||
integrations,
|
||||
media,
|
||||
billing,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -216,6 +215,95 @@ function relayConsentStamp(
|
||||
: {relay_consent_accepted_at: null, relay_consent_accepted_by: null};
|
||||
}
|
||||
|
||||
function assertSelfHostedBillingSections(data: InstanceConfigUpdateRequest): void {
|
||||
if (Config.instance.selfHosted) {
|
||||
return;
|
||||
}
|
||||
if (data.billing) {
|
||||
throw InputValidationError.create('billing', 'Billing is configured through the environment on this instance');
|
||||
}
|
||||
const branding = data.app_public?.branding;
|
||||
if (!branding) {
|
||||
return;
|
||||
}
|
||||
for (const field of ['premium_product_name', 'premium_info_url'] as const) {
|
||||
if (readOptionalField(branding, field) !== undefined) {
|
||||
throw InputValidationError.create(
|
||||
`app_public.branding.${field}`,
|
||||
'This setting is only available on self-hosted instances',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function assertBillingCompatibleWithStoredPremiumMode(
|
||||
billing: NonNullable<InstanceConfigUpdateRequest['billing']>,
|
||||
): Promise<void> {
|
||||
const requestedEnabled = readOptionalField(billing, 'enabled');
|
||||
if (requestedEnabled !== true) {
|
||||
return;
|
||||
}
|
||||
const policy = await getInstanceConfigRepository().readStoredInstancePolicyConfig();
|
||||
if (policy.premium_mode === 'everyone') {
|
||||
throw InputValidationError.create('billing.enabled', 'Billing can only be enabled when the premium mode is mirror');
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPremiumModeCompatibleWithStoredBilling(
|
||||
requestedBillingEnabled: boolean | null | undefined,
|
||||
): Promise<void> {
|
||||
if (!Config.instance.selfHosted) {
|
||||
return;
|
||||
}
|
||||
const repository = getInstanceConfigRepository();
|
||||
const [policy, billing] = await Promise.all([
|
||||
repository.readStoredInstancePolicyConfig(),
|
||||
repository.readStoredInstanceBillingConfig(),
|
||||
]);
|
||||
if (policy.premium_mode === 'everyone') {
|
||||
return;
|
||||
}
|
||||
const nextEnabled = requestedBillingEnabled === undefined ? billing.enabled : requestedBillingEnabled;
|
||||
if (nextEnabled === true) {
|
||||
throw InputValidationError.create(
|
||||
'policy.premium_mode',
|
||||
'Disable billing before switching the premium mode to everyone',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertBillingCompatibleWithPremiumMode(data: InstanceConfigUpdateRequest): Promise<void> {
|
||||
if (!Config.instance.selfHosted) {
|
||||
return;
|
||||
}
|
||||
const requestedEnabled = data.billing ? readOptionalField(data.billing, 'enabled') : undefined;
|
||||
const requestedPremiumMode = data.policy ? readOptionalField(data.policy, 'premium_mode') : undefined;
|
||||
if (requestedEnabled === undefined && requestedPremiumMode === undefined) {
|
||||
return;
|
||||
}
|
||||
const currentPremiumMode = (await getInstanceConfigRepository().getInstancePolicyConfig()).premium_mode;
|
||||
const nextPremiumMode = requestedPremiumMode ?? currentPremiumMode;
|
||||
if (nextPremiumMode !== 'everyone') {
|
||||
return;
|
||||
}
|
||||
const nextEnabled =
|
||||
requestedEnabled === undefined
|
||||
? (await getInstanceConfigRepository().readStoredInstanceBillingConfig()).enabled
|
||||
: requestedEnabled;
|
||||
if (nextEnabled !== true) {
|
||||
return;
|
||||
}
|
||||
if (requestedEnabled !== undefined) {
|
||||
throw InputValidationError.create('billing.enabled', 'Billing can only be enabled when the premium mode is mirror');
|
||||
}
|
||||
if (currentPremiumMode !== 'everyone') {
|
||||
throw InputValidationError.create(
|
||||
'policy.premium_mode',
|
||||
'Disable billing before switching the premium mode to everyone',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function listSuppliedSections(data: InstanceConfigUpdateRequest): string | undefined {
|
||||
const sections = Object.entries(data)
|
||||
.filter(([, value]) => value != null)
|
||||
@@ -271,6 +359,8 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const data = ctx.req.valid('json');
|
||||
assertSelfHostedBillingSections(data);
|
||||
await assertBillingCompatibleWithPremiumMode(data);
|
||||
const appPublicBeforeUpdate = completesInitialSetup(data, false)
|
||||
? await instanceConfigRepository.getAppPublicConfig()
|
||||
: null;
|
||||
@@ -283,18 +373,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
);
|
||||
await getGatewayRolloutConfigPublisher().publish(landed);
|
||||
}
|
||||
if (data.voice_noise_suppression) {
|
||||
const patch = omitUndefinedFields(data.voice_noise_suppression);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await instanceConfigRepository.updateVoiceNoiseSuppressionConfig((current) =>
|
||||
VoiceNoiseSuppressionConfigSchema.parse({
|
||||
...current,
|
||||
...patch,
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.push_relay) {
|
||||
const patch = omitUndefinedFields(data.push_relay);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
@@ -412,6 +490,8 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
data.app_public.branding,
|
||||
'status_page_incident_history_url',
|
||||
),
|
||||
premium_product_name: readOptionalField(data.app_public.branding, 'premium_product_name'),
|
||||
premium_info_url: readOptionalField(data.app_public.branding, 'premium_info_url'),
|
||||
})
|
||||
: undefined,
|
||||
legal: data.app_public.legal
|
||||
@@ -504,7 +584,28 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
});
|
||||
}
|
||||
if (data.policy) {
|
||||
await applyInstancePolicyUpdate(ctx, data.policy);
|
||||
await applyInstancePolicyUpdate(
|
||||
ctx,
|
||||
data.policy,
|
||||
data.billing ? readOptionalField(data.billing, 'enabled') : undefined,
|
||||
);
|
||||
}
|
||||
if (data.billing) {
|
||||
await assertBillingCompatibleWithStoredPremiumMode(data.billing);
|
||||
await instanceConfigRepository.setInstanceBillingConfig(
|
||||
omitUndefinedFields({
|
||||
enabled: readOptionalField(data.billing, 'enabled'),
|
||||
stripe_secret_key: readOptionalField(data.billing, 'stripe_secret_key'),
|
||||
stripe_webhook_secret: readOptionalField(data.billing, 'stripe_webhook_secret'),
|
||||
default_currency: readOptionalField(data.billing, 'default_currency'),
|
||||
prices: readOptionalField(data.billing, 'prices'),
|
||||
country_currencies: readOptionalField(data.billing, 'country_currencies'),
|
||||
legacy_prices: readOptionalField(data.billing, 'legacy_prices'),
|
||||
automatic_tax: readOptionalField(data.billing, 'automatic_tax'),
|
||||
tax_id_collection: readOptionalField(data.billing, 'tax_id_collection'),
|
||||
terms_consent_required: readOptionalField(data.billing, 'terms_consent_required'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (data.app_public?.setup) {
|
||||
await instanceConfigRepository.setAppPublicConfig({
|
||||
@@ -700,6 +801,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
async function applyInstancePolicyUpdate(
|
||||
ctx: Context<HonoEnv>,
|
||||
policy: NonNullable<InstanceConfigUpdateRequest['policy']>,
|
||||
requestedBillingEnabled: boolean | null | undefined,
|
||||
): Promise<void> {
|
||||
const instanceConfigRepository = getInstanceConfigRepository();
|
||||
const appPublic = await instanceConfigRepository.getAppPublicConfig();
|
||||
@@ -707,6 +809,9 @@ async function applyInstancePolicyUpdate(
|
||||
policy.single_community_enabled === true
|
||||
? await ctx.get('userRepository').findUnique(ctx.get('adminUserId'))
|
||||
: null;
|
||||
if (policy.premium_mode === 'everyone') {
|
||||
await assertPremiumModeCompatibleWithStoredBilling(requestedBillingEnabled);
|
||||
}
|
||||
let enablesSingleCommunity = false;
|
||||
await instanceConfigRepository.updateInstancePolicyConfig((current) => {
|
||||
const planned = planInstancePolicyPatch(policy, current, {
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
|
||||
import {getAdminRepository, getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {getStoredBillingConfig, setStoredBillingConfig} from '@app/api/stripe/BillingConfigCache';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const OPERATOR_SECRET_KEY = 'sk_test_operator_secret_value';
|
||||
const OPERATOR_WEBHOOK_SECRET = 'whsec_operator_secret_value';
|
||||
|
||||
interface GlobalState {
|
||||
selfHosted: boolean;
|
||||
premiumMode: ReturnType<typeof getCachedInstancePremiumMode>;
|
||||
storedBilling: ReturnType<typeof getStoredBillingConfig>;
|
||||
}
|
||||
|
||||
function captureGlobalState(): GlobalState {
|
||||
return {
|
||||
selfHosted: getConfig().instance.selfHosted,
|
||||
premiumMode: getCachedInstancePremiumMode(),
|
||||
storedBilling: getStoredBillingConfig(),
|
||||
};
|
||||
}
|
||||
|
||||
function restoreGlobalState(state: GlobalState): void {
|
||||
getConfig().instance.selfHosted = state.selfHosted;
|
||||
setCachedInstancePremiumMode(state.premiumMode);
|
||||
setStoredBillingConfig(state.storedBilling);
|
||||
}
|
||||
|
||||
function useInstanceHarness(selfHosted: boolean) {
|
||||
const context: {harness: ApiTestHarness} = {harness: undefined as unknown as ApiTestHarness};
|
||||
let original: GlobalState;
|
||||
beforeAll(async () => {
|
||||
original = captureGlobalState();
|
||||
getConfig().instance.selfHosted = selfHosted;
|
||||
context.harness = await createApiTestHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await context.harness.reset();
|
||||
setStoredBillingConfig(null);
|
||||
setCachedInstancePremiumMode('everyone');
|
||||
});
|
||||
afterAll(async () => {
|
||||
await context.harness.shutdown();
|
||||
restoreGlobalState(original);
|
||||
});
|
||||
return context;
|
||||
}
|
||||
|
||||
async function createAdmin(harness: ApiTestHarness): Promise<TestAccount> {
|
||||
return await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
}
|
||||
|
||||
function patchConfig(harness: ApiTestHarness, admin: TestAccount, body: Record<string, unknown>) {
|
||||
return createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
|
||||
}
|
||||
|
||||
const OPERATOR_BILLING = {
|
||||
enabled: true,
|
||||
stripe_secret_key: OPERATOR_SECRET_KEY,
|
||||
stripe_webhook_secret: OPERATOR_WEBHOOK_SECRET,
|
||||
default_currency: 'GBP',
|
||||
prices: {
|
||||
GBP: {
|
||||
monthly: 'price_monthlygbp',
|
||||
yearly: 'price_yearlygbp',
|
||||
gift_1_month: 'price_gift1monthgbp',
|
||||
gift_1_year: 'price_gift1yeargbp',
|
||||
},
|
||||
},
|
||||
country_currencies: {GB: 'GBP'},
|
||||
legacy_prices: {monthly_GBP: ['price_oldmonthlygbp']},
|
||||
};
|
||||
|
||||
describe('instance config billing on a self-hosted instance', () => {
|
||||
const context = useInstanceHarness(true);
|
||||
|
||||
it('rejects enabling billing while the premium mode is everyone', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {billing: {enabled: true}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
await patchConfig(context.harness, admin, {billing: {enabled: true}, policy: {premium_mode: 'everyone'}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(current.billing.enabled).toBeNull();
|
||||
expect(current.policy.premium_mode).toBe('everyone');
|
||||
});
|
||||
|
||||
it('saves the environment fallback in everyone mode even when the environment enables billing', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
const originalEnabled = getConfig().stripe.enabled;
|
||||
getConfig().stripe.enabled = true;
|
||||
try {
|
||||
const updated = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {premium_product_name: 'Gold'}},
|
||||
billing: {enabled: null},
|
||||
}).execute();
|
||||
expect(updated.billing.enabled).toBeNull();
|
||||
expect(updated.billing.billing_active).toBe(false);
|
||||
expect(updated.app_public.branding.premium_product_name).toBe('Gold');
|
||||
await patchConfig(context.harness, admin, {policy: {premium_mode: 'everyone'}}).execute();
|
||||
} finally {
|
||||
getConfig().stripe.enabled = originalEnabled;
|
||||
}
|
||||
});
|
||||
|
||||
it('enables billing and mirror mode from one request and redacts the secrets', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
const updated = await patchConfig(context.harness, admin, {
|
||||
billing: OPERATOR_BILLING,
|
||||
policy: {premium_mode: 'mirror'},
|
||||
}).execute();
|
||||
expect(updated.policy.premium_mode).toBe('mirror');
|
||||
expect(updated.billing).toMatchObject({
|
||||
enabled: true,
|
||||
effective_enabled: true,
|
||||
stripe_secret_key_set: true,
|
||||
stripe_webhook_secret_set: true,
|
||||
default_currency: 'GBP',
|
||||
prices: OPERATOR_BILLING.prices,
|
||||
country_currencies: {GB: 'GBP'},
|
||||
legacy_prices: {monthly_GBP: ['price_oldmonthlygbp']},
|
||||
billing_active: true,
|
||||
catalog_mode: 'operator',
|
||||
});
|
||||
expect(updated.billing.webhook_url).toMatch(/\/stripe\/webhook$/);
|
||||
const {text} = await createBuilder(context.harness, admin.token).get('/admin/instance/config').executeRaw();
|
||||
expect(text).not.toContain(OPERATOR_SECRET_KEY);
|
||||
expect(text).not.toContain(OPERATOR_WEBHOOK_SECRET);
|
||||
expect(JSON.parse(text).billing).toMatchObject({stripe_secret_key_set: true, billing_active: true});
|
||||
});
|
||||
|
||||
it('rejects switching the premium mode to everyone while billing is enabled', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {billing: OPERATOR_BILLING, policy: {premium_mode: 'mirror'}}).execute();
|
||||
await patchConfig(context.harness, admin, {policy: {premium_mode: 'everyone'}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
const unchanged = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(unchanged.policy.premium_mode).toBe('mirror');
|
||||
const switched = await patchConfig(context.harness, admin, {
|
||||
billing: {enabled: false},
|
||||
policy: {premium_mode: 'everyone'},
|
||||
}).execute();
|
||||
expect(switched.policy.premium_mode).toBe('everyone');
|
||||
expect(switched.billing).toMatchObject({enabled: false, effective_enabled: false, billing_active: false});
|
||||
});
|
||||
|
||||
it('clears secrets and the operator catalog with null', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {billing: OPERATOR_BILLING, policy: {premium_mode: 'mirror'}}).execute();
|
||||
const kept = await patchConfig(context.harness, admin, {billing: {default_currency: 'GBP'}}).execute();
|
||||
expect(kept.billing.prices).toEqual(OPERATOR_BILLING.prices);
|
||||
const cleared = await patchConfig(context.harness, admin, {
|
||||
billing: {enabled: true, stripe_webhook_secret: null, prices: null, legacy_prices: null},
|
||||
}).execute();
|
||||
expect(cleared.billing).toMatchObject({
|
||||
enabled: true,
|
||||
prices: null,
|
||||
legacy_prices: null,
|
||||
catalog_mode: 'env',
|
||||
stripe_secret_key_set: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects malformed billing input', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
for (const billing of [
|
||||
{prices: {gbp: {monthly: 'price_x'}}},
|
||||
{prices: {GBP: {monthly: 'not_a_price'}}},
|
||||
{country_currencies: {GBR: 'GBP'}},
|
||||
{legacy_prices: {weekly_GBP: ['price_x']}},
|
||||
{default_currency: 'pounds'},
|
||||
]) {
|
||||
await patchConfig(context.harness, admin, {billing})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
|
||||
it('clears the premium name and info URL when both are sent as null', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {premium_product_name: 'Gold', premium_info_url: 'https://example.com/gold'}},
|
||||
}).execute();
|
||||
const reset = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {premium_product_name: null, premium_info_url: null}},
|
||||
}).execute();
|
||||
expect(reset.app_public.branding).toMatchObject({premium_product_name: 'Premium', premium_info_url: null});
|
||||
await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {premium_product_name: 'Gold', premium_info_url: 'https://example.com/gold'}},
|
||||
}).execute();
|
||||
const resetWithBilling = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {premium_product_name: null, premium_info_url: null}},
|
||||
billing: {enabled: false},
|
||||
}).execute();
|
||||
expect(resetWithBilling.app_public.branding).toMatchObject({
|
||||
premium_product_name: 'Premium',
|
||||
premium_info_url: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies billing sections that only hold nulls', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {billing: OPERATOR_BILLING, policy: {premium_mode: 'mirror'}}).execute();
|
||||
const envCatalog = await patchConfig(context.harness, admin, {billing: {prices: null}}).execute();
|
||||
expect(envCatalog.billing).toMatchObject({prices: null, catalog_mode: 'env', enabled: true});
|
||||
const followsEnv = await patchConfig(context.harness, admin, {billing: {enabled: null}}).execute();
|
||||
expect(followsEnv.billing).toMatchObject({enabled: null, effective_enabled: getConfig().stripe.enabled});
|
||||
});
|
||||
|
||||
it('applies the policy before billing so a failed policy change stores no billing', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {app_public: {setup: {configured: true}}}).execute();
|
||||
await patchConfig(context.harness, admin, {
|
||||
billing: {enabled: true},
|
||||
policy: {premium_mode: 'mirror', single_community_enabled: true},
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(current.policy.premium_mode).toBe('everyone');
|
||||
expect(current.billing.enabled).toBeNull();
|
||||
});
|
||||
|
||||
it('re-checks the stored premium mode right before the billing write', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
const repository = getInstanceConfigRepository();
|
||||
const stored = await repository.getInstancePolicyConfig();
|
||||
const spy = vi.spyOn(repository, 'getInstancePolicyConfig').mockResolvedValue({...stored, premium_mode: 'mirror'});
|
||||
try {
|
||||
await patchConfig(context.harness, admin, {billing: {enabled: true}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(current.billing.enabled).toBeNull();
|
||||
});
|
||||
|
||||
it('re-checks the stored billing right before the premium mode write', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {billing: OPERATOR_BILLING, policy: {premium_mode: 'mirror'}}).execute();
|
||||
const cached = getStoredBillingConfig();
|
||||
expect(cached?.enabled).toBe(true);
|
||||
setStoredBillingConfig(cached === null ? null : {...cached, enabled: false});
|
||||
await patchConfig(context.harness, admin, {policy: {premium_mode: 'everyone'}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(current.policy.premium_mode).toBe('mirror');
|
||||
expect(current.billing.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('records the billing section in the audit log without secrets', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {billing: OPERATOR_BILLING, policy: {premium_mode: 'mirror'}}).execute();
|
||||
const logs = await getAdminRepository().listAllAuditLogsPaginated(100000);
|
||||
const update = logs.find((log) => log.action === 'update_instance_config');
|
||||
expect(update?.metadata.get('sections')).toBe('billing,policy');
|
||||
const serialized = JSON.stringify(logs.map((log) => [...log.metadata.entries()]));
|
||||
expect(serialized).not.toContain(OPERATOR_SECRET_KEY);
|
||||
expect(serialized).not.toContain(OPERATOR_WEBHOOK_SECRET);
|
||||
});
|
||||
|
||||
it('stores the premium name and info URL and resets the name to the self-hosted default', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
const initial = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(initial.app_public.branding).toMatchObject({premium_product_name: 'Premium', premium_info_url: null});
|
||||
const named = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {premium_product_name: 'Gold', premium_info_url: 'https://example.com/gold'}},
|
||||
}).execute();
|
||||
expect(named.app_public.branding).toMatchObject({
|
||||
premium_product_name: 'Gold',
|
||||
premium_info_url: 'https://example.com/gold',
|
||||
});
|
||||
const renamedProduct = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {product_name: 'Example Chat'}},
|
||||
}).execute();
|
||||
expect(renamedProduct.app_public.branding.premium_product_name).toBe('Gold');
|
||||
const reset = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {product_name: 'Example Chat', premium_product_name: null, premium_info_url: null}},
|
||||
}).execute();
|
||||
expect(reset.app_public.branding).toMatchObject({premium_product_name: 'Premium', premium_info_url: null});
|
||||
await patchConfig(context.harness, admin, {app_public: {branding: {premium_info_url: 'javascript:alert(1)'}}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('instance config billing on a hosted instance', () => {
|
||||
const context = useInstanceHarness(false);
|
||||
|
||||
it('keeps the Plutonium default and env billing', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(current.app_public.branding.premium_product_name).toBe('Plutonium');
|
||||
expect(current.billing).toMatchObject({enabled: null, catalog_mode: 'env', stripe_secret_key_set: true});
|
||||
const updated = await patchConfig(context.harness, admin, {policy: {premium_mode: 'everyone'}}).execute();
|
||||
expect(updated.policy.premium_mode).toBe('everyone');
|
||||
});
|
||||
|
||||
it('rejects the billing section and the premium branding fields', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
for (const body of [
|
||||
{billing: {enabled: true}},
|
||||
{billing: {enabled: null}},
|
||||
{billing: {prices: null}},
|
||||
{billing: OPERATOR_BILLING},
|
||||
{app_public: {branding: {premium_product_name: 'Gold'}}},
|
||||
{app_public: {branding: {premium_info_url: 'https://example.com/gold'}}},
|
||||
{app_public: {branding: {premium_product_name: null, premium_info_url: null}}},
|
||||
{app_public: {branding: {product_name: 'Fluxer', premium_product_name: 'Gold'}}},
|
||||
]) {
|
||||
await patchConfig(context.harness, admin, body)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
}
|
||||
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(current.billing).toMatchObject({enabled: null, catalog_mode: 'env', prices: null});
|
||||
expect(current.app_public.branding).toMatchObject({premium_product_name: 'Plutonium', premium_info_url: null});
|
||||
expect(getStoredBillingConfig()?.prices ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('still accepts other branding fields', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
const updated = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {theme_color: '#123456'}},
|
||||
}).execute();
|
||||
expect(updated.app_public.branding.theme_color).toBe('#123456');
|
||||
});
|
||||
});
|
||||
@@ -81,8 +81,8 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
|
||||
PremiumController(routes);
|
||||
if (!config.instance.selfHosted) {
|
||||
DonationController(routes);
|
||||
StripeController(routes);
|
||||
}
|
||||
StripeController(routes);
|
||||
}
|
||||
|
||||
function registerInboundSmsWebhook(routes: HonoApp): void {
|
||||
|
||||
@@ -333,13 +333,10 @@ export function MessageController(app: HonoApp) {
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Channels', 'Messages'],
|
||||
description:
|
||||
'Clears all read state and acknowledgement records for a channel, marking all messages as unread. Returns 204 No Content on success.',
|
||||
deprecated: true,
|
||||
description: 'Deprecated. Has no effect on the read state. Returns 204 No Content.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
await ctx.get('readStateService').deleteReadState({userId, channelId});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -62,6 +62,8 @@ describe('DM Privacy Bidirectional Enforcement', () => {
|
||||
const target = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, sender.token);
|
||||
await ensureSessionStarted(harness, target.token);
|
||||
await updateUserSettings(harness, sender.token, {default_guilds_restricted: false});
|
||||
await updateUserSettings(harness, target.token, {default_guilds_restricted: false});
|
||||
const guild = await createGuild(harness, sender.token, 'Mutual Community');
|
||||
const systemChannel = await getChannel(harness, sender.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, sender.token, systemChannel.id);
|
||||
@@ -80,6 +82,8 @@ describe('DM Privacy Bidirectional Enforcement', () => {
|
||||
await ensureSessionStarted(harness, sender.token);
|
||||
await ensureSessionStarted(harness, target.token);
|
||||
await createFriendship(harness, sender, target);
|
||||
await updateUserSettings(harness, sender.token, {default_guilds_restricted: false});
|
||||
await updateUserSettings(harness, target.token, {default_guilds_restricted: false});
|
||||
const guild = await createGuild(harness, sender.token, 'Verified Community');
|
||||
await createBuilder(harness, '')
|
||||
.post(`/test/guilds/${guild.id}/features`)
|
||||
@@ -97,6 +101,25 @@ describe('DM Privacy Bidirectional Enforcement', () => {
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
it('blocks message from a non-friend guild member to a new account by default', async () => {
|
||||
const sender = await createTestAccount(harness);
|
||||
const target = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, sender.token);
|
||||
await ensureSessionStarted(harness, target.token);
|
||||
await updateUserSettings(harness, sender.token, {default_guilds_restricted: false});
|
||||
await createFriendship(harness, sender, target);
|
||||
const guild = await createGuild(harness, sender.token, 'Default Community');
|
||||
const systemChannel = await getChannel(harness, sender.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, sender.token, systemChannel.id);
|
||||
await acceptInvite(harness, target.token, invite.code);
|
||||
const channel = await createDmChannel(harness, sender.token, target.userId);
|
||||
await removeRelationship(harness, sender.token, target.userId);
|
||||
await createBuilder(harness, sender.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({content: 'default restricted target'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'CANNOT_SEND_MESSAGES_TO_USER')
|
||||
.execute();
|
||||
});
|
||||
it('blocks message when sender restricts the only mutual guild', async () => {
|
||||
const sender = await createTestAccount(harness);
|
||||
const target = await createTestAccount(harness);
|
||||
@@ -175,6 +198,8 @@ describe('DM Privacy Bidirectional Enforcement', () => {
|
||||
const user2 = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
await updateUserSettings(harness, user1.token, {default_guilds_restricted: false});
|
||||
await updateUserSettings(harness, user2.token, {default_guilds_restricted: false});
|
||||
const guild = await createGuild(harness, user1.token, 'Shared Community');
|
||||
const systemChannel = await getChannel(harness, user1.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, user1.token, systemChannel.id);
|
||||
|
||||
@@ -12,7 +12,6 @@ import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
|
||||
import {resolveAltchaCaptchaAssignment} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {resolveDomainMigrationAssignment} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {resolveProfileTimezoneAssignment} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import {resolveVoiceNoiseSuppressionAssignment} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
|
||||
export function ExperimentController(app: HonoApp) {
|
||||
@@ -32,18 +31,15 @@ export function ExperimentController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const [delivery, voiceConfig, domainMigrationConfig, altchaCaptchaConfig, profileTimezoneConfig] =
|
||||
await Promise.all([
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getAltchaCaptchaConfig(),
|
||||
instanceConfigRepository.getProfileTimezoneConfig(),
|
||||
]);
|
||||
const [delivery, domainMigrationConfig, altchaCaptchaConfig, profileTimezoneConfig] = await Promise.all([
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getAltchaCaptchaConfig(),
|
||||
instanceConfigRepository.getProfileTimezoneConfig(),
|
||||
]);
|
||||
const user = ctx.get('user');
|
||||
const userId = user.id.toString();
|
||||
const targeting = await resolveExperimentTargeting(user, [
|
||||
voiceConfig,
|
||||
domainMigrationConfig,
|
||||
altchaCaptchaConfig,
|
||||
profileTimezoneConfig,
|
||||
@@ -52,7 +48,6 @@ export function ExperimentController(app: HonoApp) {
|
||||
poll_interval_seconds: delivery.poll_interval_seconds,
|
||||
poll_jitter_percent: delivery.poll_jitter_percent,
|
||||
assignments: {
|
||||
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId, targeting),
|
||||
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId, targeting),
|
||||
altcha_captcha: resolveAltchaCaptchaAssignment(altchaCaptchaConfig, userId, targeting),
|
||||
profile_timezone: resolveProfileTimezoneAssignment(profileTimezoneConfig, userId, targeting),
|
||||
|
||||
@@ -21,17 +21,12 @@ import {
|
||||
DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
INERT_PROFILE_TIMEZONE_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import {
|
||||
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {
|
||||
DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
|
||||
DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
type ExperimentAssignmentsResponse,
|
||||
type ExperimentDeliveryConfigResponse,
|
||||
readDomainMigrationAssignment,
|
||||
readVoiceNoiseSuppressionAssignment,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
@@ -66,7 +61,6 @@ describe('GET /experiments', () => {
|
||||
poll_interval_seconds: DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
|
||||
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
assignments: {
|
||||
voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
altcha_captcha: INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
|
||||
profile_timezone: INERT_PROFILE_TIMEZONE_ASSIGNMENT,
|
||||
@@ -74,33 +68,6 @@ describe('GET /experiments', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the inert assignment while the stored config is disabled but populated', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setVoiceNoiseSuppressionConfig({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled: false,
|
||||
config_version: 9,
|
||||
rollout_basis_points: 10000,
|
||||
included_user_ids: [account.userId],
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(body.assignments.voice_noise_suppression).toEqual({
|
||||
...INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
config_version: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it('populates the voice assignment key even when the rollout is disabled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(Object.hasOwn(body.assignments, 'voice_noise_suppression')).toBe(true);
|
||||
expect(readVoiceNoiseSuppressionAssignment(body).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('populates the domain migration assignment key even when the rollout is disabled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
@@ -262,11 +229,6 @@ describe('GET /experiments', () => {
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
const repository = getInstanceConfigRepository();
|
||||
await repository.setVoiceNoiseSuppressionConfig({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
await repository.setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
@@ -286,7 +248,6 @@ describe('GET /experiments', () => {
|
||||
const memberBody = await createBuilder<ExperimentAssignmentsResponse>(harness, member.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(memberBody.assignments.voice_noise_suppression).toMatchObject({user_targeted: true, source: 'user_rule'});
|
||||
expect(memberBody.assignments.domain_migration).toEqual({enabled: true});
|
||||
expect(memberBody.assignments.altcha_captcha).toEqual({enabled: true});
|
||||
expect(memberBody.assignments.profile_timezone).toEqual({enabled: true});
|
||||
@@ -294,7 +255,6 @@ describe('GET /experiments', () => {
|
||||
const outsiderBody = await createBuilder<ExperimentAssignmentsResponse>(harness, outsider.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(outsiderBody.assignments.voice_noise_suppression).toMatchObject({user_targeted: false, source: null});
|
||||
expect(outsiderBody.assignments.domain_migration).toEqual({enabled: false});
|
||||
expect(outsiderBody.assignments.altcha_captcha).toEqual({enabled: false});
|
||||
expect(outsiderBody.assignments.profile_timezone).toEqual({enabled: false});
|
||||
@@ -330,89 +290,25 @@ describe('GET /experiments', () => {
|
||||
const guildIds = ['1500000000000000001', '1500000000000000002'];
|
||||
const body = await createBuilder<
|
||||
Record<
|
||||
'voice_noise_suppression' | 'domain_migration' | 'altcha_captcha' | 'profile_timezone',
|
||||
'domain_migration' | 'altcha_captcha' | 'profile_timezone',
|
||||
{included_guild_ids: Array<string>; include_premium_users: boolean}
|
||||
>
|
||||
>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({
|
||||
voice_noise_suppression: {included_guild_ids: guildIds, include_premium_users: true},
|
||||
domain_migration: {included_guild_ids: guildIds, include_premium_users: true},
|
||||
altcha_captcha: {included_guild_ids: guildIds, include_premium_users: true},
|
||||
profile_timezone: {included_guild_ids: guildIds, include_premium_users: true},
|
||||
})
|
||||
.execute();
|
||||
expect(body.voice_noise_suppression.included_guild_ids).toEqual(guildIds);
|
||||
expect(body.domain_migration.included_guild_ids).toEqual(guildIds);
|
||||
expect(body.altcha_captcha.included_guild_ids).toEqual(guildIds);
|
||||
expect(body.profile_timezone.included_guild_ids).toEqual(guildIds);
|
||||
for (const section of [
|
||||
body.voice_noise_suppression,
|
||||
body.domain_migration,
|
||||
body.altcha_captcha,
|
||||
body.profile_timezone,
|
||||
]) {
|
||||
for (const section of [body.domain_migration, body.altcha_captcha, body.profile_timezone]) {
|
||||
expect(section.include_premium_users).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('serves the delivery cadence from the delivery config and not from the voice config', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setExperimentDeliveryConfig({
|
||||
poll_interval_seconds: 7200,
|
||||
poll_jitter_percent: 45,
|
||||
});
|
||||
await getInstanceConfigRepository().setVoiceNoiseSuppressionConfig({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 3,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(body.poll_interval_seconds).toBe(7200);
|
||||
expect(body.poll_jitter_percent).toBe(45);
|
||||
expect(body.assignments.voice_noise_suppression).toMatchObject({enabled: true, config_version: 3});
|
||||
expect(body.assignments.voice_noise_suppression).not.toHaveProperty('poll_interval_seconds');
|
||||
expect(body.assignments.voice_noise_suppression).not.toHaveProperty('poll_jitter_percent');
|
||||
});
|
||||
|
||||
it('echoes the config version and resolves the caller through the allowlist', async () => {
|
||||
const targeted = await createTestAccount(harness);
|
||||
const untargeted = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setVoiceNoiseSuppressionConfig({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 14,
|
||||
default_backend: 'rnnoise',
|
||||
rollout_basis_points: 0,
|
||||
included_user_ids: [targeted.userId],
|
||||
});
|
||||
|
||||
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(targetedBody.assignments.voice_noise_suppression).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 14,
|
||||
user_targeted: true,
|
||||
backend: 'rnnoise',
|
||||
source: 'user_rule',
|
||||
});
|
||||
|
||||
const untargetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, untargeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(untargetedBody.assignments.voice_noise_suppression).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 14,
|
||||
user_targeted: false,
|
||||
backend: null,
|
||||
source: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('revalidates with a strong etag and answers 304 when nothing changed', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
@@ -442,34 +338,6 @@ describe('GET /experiments', () => {
|
||||
expect(preflight.headers.get('access-control-expose-headers')).toContain('ETag');
|
||||
});
|
||||
|
||||
it('serves a fresh body once the voice config changes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const first = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.executeWithResponse();
|
||||
const staleEtag = first.response.headers.get('etag') as string;
|
||||
|
||||
await getInstanceConfigRepository().setVoiceNoiseSuppressionConfig({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
|
||||
const refreshed = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.header('If-None-Match', staleEtag)
|
||||
.executeWithResponse();
|
||||
expect(refreshed.response.status).toBe(HTTP_STATUS.OK);
|
||||
expect(refreshed.response.headers.get('etag')).not.toBe(staleEtag);
|
||||
expect(refreshed.json?.assignments.voice_noise_suppression).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
user_targeted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('serves a fresh body once the domain migration config changes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
@@ -517,39 +385,6 @@ describe('GET /experiments', () => {
|
||||
expect(refreshed.json?.poll_jitter_percent).toBe(5);
|
||||
});
|
||||
|
||||
it('bumps the config version on every admin update without the client sending one', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const afterFirst = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_noise_suppression: {enabled: true, rollout_basis_points: 10000}})
|
||||
.execute();
|
||||
expect(afterFirst.voice_noise_suppression).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterSecond = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_noise_suppression: {suppression_strength: 42}})
|
||||
.execute();
|
||||
expect(afterSecond.voice_noise_suppression).toMatchObject({config_version: 2, enabled: true});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.voice_noise_suppression).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
suppression_strength: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('bumps the domain migration config version on every admin update without the client sending one', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
@@ -591,39 +426,39 @@ describe('GET /experiments', () => {
|
||||
expect(body.assignments.domain_migration).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
it('leaves the config version alone for an admin update that sets no field', async () => {
|
||||
it('leaves the domain migration config version alone for an admin update that sets no field', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const afterFirst = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
|
||||
const afterFirst = await createBuilder<{domain_migration: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_noise_suppression: {enabled: true}})
|
||||
.body({domain_migration: {enabled: true}})
|
||||
.execute();
|
||||
expect(afterFirst.voice_noise_suppression).toMatchObject({config_version: 1, enabled: true});
|
||||
expect(afterFirst.domain_migration).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterEmpty = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
|
||||
const afterEmpty = await createBuilder<{domain_migration: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_noise_suppression: {}})
|
||||
.body({domain_migration: {}})
|
||||
.execute();
|
||||
expect(afterEmpty.voice_noise_suppression).toMatchObject({config_version: 1, enabled: true});
|
||||
expect(afterEmpty.domain_migration).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterUndefined = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
|
||||
const afterUndefined = await createBuilder<{domain_migration: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_noise_suppression: {enabled: undefined}})
|
||||
.body({domain_migration: {enabled: undefined}})
|
||||
.execute();
|
||||
expect(afterUndefined.voice_noise_suppression).toMatchObject({config_version: 1, enabled: true});
|
||||
expect(afterUndefined.domain_migration).toMatchObject({config_version: 1, enabled: true});
|
||||
});
|
||||
|
||||
it('serves the delivery cadence an admin set through the instance config', async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {isPremiumTieringActive} from '@app/api/stripe/BillingConfigCache';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {NON_SELF_HOSTED_RESERVED_DISCRIMINATORS} from '@fluxer/constants/src/DiscriminatorConstants';
|
||||
@@ -64,7 +65,7 @@ export class DiscriminatorService implements IDiscriminatorService {
|
||||
) {}
|
||||
|
||||
private async canUseCustomDiscriminator(user?: User | null): Promise<boolean> {
|
||||
if (Config.instance.selfHosted) {
|
||||
if (Config.instance.selfHosted && !isPremiumTieringActive()) {
|
||||
return true;
|
||||
}
|
||||
if (!user) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {createDefaultLimitConfig} from '@app/api/constants/LimitConfig';
|
||||
import {DiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
|
||||
import {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
|
||||
import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
function createService(): DiscriminatorService {
|
||||
const userRepository = {
|
||||
async findByUsernameDiscriminator() {
|
||||
return null;
|
||||
},
|
||||
async findDiscriminatorsByUsername() {
|
||||
return new Set<number>([42]);
|
||||
},
|
||||
} as unknown as IUserRepository;
|
||||
const cacheService = {
|
||||
async acquireLock() {
|
||||
return 'token';
|
||||
},
|
||||
async releaseLock() {},
|
||||
async sismember() {
|
||||
return false;
|
||||
},
|
||||
async sadd() {},
|
||||
async smembers() {
|
||||
return new Set<string>();
|
||||
},
|
||||
} as unknown as ICacheService;
|
||||
const limitConfigService = {
|
||||
getConfigSnapshot: () => createDefaultLimitConfig({selfHosted: true, premiumMode: 'mirror'}),
|
||||
} as unknown as LimitConfigService;
|
||||
return new DiscriminatorService(userRepository, cacheService, limitConfigService);
|
||||
}
|
||||
|
||||
describe('DiscriminatorService on a self-hosted instance', () => {
|
||||
let originalSelfHosted: boolean;
|
||||
let originalPremiumMode: ReturnType<typeof getCachedInstancePremiumMode>;
|
||||
|
||||
beforeEach(() => {
|
||||
originalSelfHosted = getConfig().instance.selfHosted;
|
||||
originalPremiumMode = getCachedInstancePremiumMode();
|
||||
getConfig().instance.selfHosted = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getConfig().instance.selfHosted = originalSelfHosted;
|
||||
setCachedInstancePremiumMode(originalPremiumMode);
|
||||
});
|
||||
|
||||
test('lets anyone pick a discriminator when everyone is premium', async () => {
|
||||
setCachedInstancePremiumMode('everyone');
|
||||
const result = await createService().generateDiscriminator({username: 'someone', requestedDiscriminator: 42});
|
||||
expect(result).toEqual({discriminator: 42, available: true});
|
||||
});
|
||||
|
||||
test('follows the custom discriminator limit in mirror mode', async () => {
|
||||
setCachedInstancePremiumMode('mirror');
|
||||
const result = await createService().generateDiscriminator({username: 'someone', requestedDiscriminator: 42});
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.discriminator).not.toBe(42);
|
||||
});
|
||||
});
|
||||
@@ -22,10 +22,6 @@ import {
|
||||
DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
type DomainMigrationConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {
|
||||
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
type VoiceNoiseSuppressionConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {
|
||||
DEFAULT_EXPERIMENT_DELIVERY_CONFIG,
|
||||
type ExperimentDeliveryConfig,
|
||||
@@ -38,7 +34,6 @@ import {
|
||||
} from '@pkgs/postgres/src/Client';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
|
||||
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
|
||||
@@ -287,75 +282,6 @@ describe('InstanceConfigRepository', () => {
|
||||
expect(domains).not.toContain('example.com');
|
||||
});
|
||||
|
||||
it('returns the default voice noise suppression config when the key is absent', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await expect(repository.getVoiceNoiseSuppressionConfig()).resolves.toEqual(DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{name: 'unparseable text', stored: 'not-json'},
|
||||
{name: 'a json array', stored: '[]'},
|
||||
{name: 'out-of-range values', stored: '{"rollout_basis_points":99999}'},
|
||||
{name: 'an unknown backend', stored: '{"default_backend":"magic"}'},
|
||||
])('falls back to the default voice noise suppression config for $name', async ({stored}) => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY, stored);
|
||||
|
||||
await expect(repository.getVoiceNoiseSuppressionConfig()).resolves.toEqual(DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG);
|
||||
});
|
||||
|
||||
it('round-trips a stored voice noise suppression config', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
const config: VoiceNoiseSuppressionConfig = {
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 3,
|
||||
default_backend: 'rnnoise',
|
||||
enabled_backends: ['none', 'standard', 'rnnoise'],
|
||||
allow_user_override: false,
|
||||
rollout_basis_points: 2500,
|
||||
rollout_salt: 'voice-ns-v2',
|
||||
included_user_ids: ['1400000000000000001'],
|
||||
excluded_user_ids: ['1400000000000000002'],
|
||||
guild_overrides: [{guild_id: '2400000000000000001', backend: 'rnnoise'}],
|
||||
suppression_strength: 55,
|
||||
};
|
||||
await repository.setVoiceNoiseSuppressionConfig(config);
|
||||
|
||||
await expect(repository.getVoiceNoiseSuppressionConfig()).resolves.toEqual(config);
|
||||
});
|
||||
|
||||
it('fills newly added voice noise suppression fields from the schema defaults', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig(
|
||||
VOICE_NOISE_SUPPRESSION_CONFIG_KEY,
|
||||
JSON.stringify({enabled: true, config_version: 2, rollout_basis_points: 1000}),
|
||||
);
|
||||
|
||||
await expect(repository.getVoiceNoiseSuppressionConfig()).resolves.toEqual({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
rollout_basis_points: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the default domain migration config when the key is absent', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
@@ -494,26 +420,6 @@ describe('InstanceConfigRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes a refresh so another repository observes the voice noise suppression config', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const reader = createRepository(kvProvider);
|
||||
const writer = createRepository(kvProvider);
|
||||
|
||||
await expect(reader.getVoiceNoiseSuppressionConfig()).resolves.toEqual(DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG);
|
||||
|
||||
await writer.setVoiceNoiseSuppressionConfig({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
});
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
expect(await reader.getVoiceNoiseSuppressionConfig()).toMatchObject({enabled: true, config_version: 1});
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the registration URL id as the admin-visible registration code', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
|
||||
@@ -15,6 +15,12 @@ import {normalizeSsoAllowedEmailDomains} from '@app/api/instance/SsoConfigValida
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {isLimitConfigSnapshot} from '@app/api/limits/LimitConfigValidation';
|
||||
import {resolveDeferredPhoneGateEnabled, setCachedDeferredPhoneGateEnabled} from '@app/api/risk/DeferredPhoneGateCache';
|
||||
import {
|
||||
getEffectiveBillingConfig,
|
||||
isBillingActive,
|
||||
isStripeServiceable,
|
||||
setStoredBillingConfig,
|
||||
} from '@app/api/stripe/BillingConfigCache';
|
||||
import {InstanceConfiguration} from '@app/api/Tables';
|
||||
import {DEFAULT_DECAY_CONSTANTS, DEFAULT_RENEWAL_CONSTANTS} from '@app/api/utils/AttachmentDecay';
|
||||
import {isJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
|
||||
@@ -40,6 +46,11 @@ import {
|
||||
type GatewayRolloutConfig,
|
||||
GatewayRolloutConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {
|
||||
type BillingCatalogMode,
|
||||
type StoredBillingConfig,
|
||||
StoredBillingConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/InstanceBillingSchemas';
|
||||
import {
|
||||
type ProfileTimezoneConfig,
|
||||
ProfileTimezoneConfigSchema,
|
||||
@@ -50,10 +61,6 @@ import {
|
||||
PushRelayConfigSchema,
|
||||
toLegacyPushServiceDeliveryWire,
|
||||
} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import {
|
||||
type VoiceNoiseSuppressionConfig,
|
||||
VoiceNoiseSuppressionConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {
|
||||
type ExperimentDeliveryConfig,
|
||||
ExperimentDeliveryConfigSchema,
|
||||
@@ -75,7 +82,6 @@ import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {z} from 'zod';
|
||||
|
||||
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
|
||||
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
|
||||
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
|
||||
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
|
||||
const ALTCHA_CAPTCHA_CONFIG_KEY = 'altcha_captcha_config';
|
||||
@@ -90,6 +96,7 @@ const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
|
||||
const LIMIT_CONFIG_KEY = 'limit_config';
|
||||
const INSTANCE_INTEGRATIONS_CONFIG_KEY = 'instance_integrations_config';
|
||||
const INSTANCE_MEDIA_CONFIG_KEY = 'instance_media_config';
|
||||
const INSTANCE_BILLING_CONFIG_KEY = 'instance_billing_config';
|
||||
export const INSTANCE_CONFIG_REFRESH_CHANNEL = 'instance-config-refresh';
|
||||
export const REGISTRATION_PENDING_APPROVAL_TRAIT = 'registration_pending_approval';
|
||||
export const REGISTRATION_REJECTED_TRAIT = 'registration_rejected';
|
||||
@@ -116,6 +123,50 @@ interface InstanceAppPublicConfig extends Omit<InstanceAppPublic, 'setup'> {
|
||||
setup: Pick<InstanceSetup, 'configured'>;
|
||||
}
|
||||
|
||||
type InstanceBrandingPatch = Partial<Omit<InstanceBranding, 'premium_product_name'>> & {
|
||||
premium_product_name?: string | null;
|
||||
};
|
||||
|
||||
export type InstanceBillingConfig = StoredBillingConfig;
|
||||
|
||||
export type InstanceBillingPriceSetPatch = Partial<NonNullable<StoredBillingConfig['prices']>[string]>;
|
||||
|
||||
export interface InstanceBillingConfigPatch {
|
||||
enabled?: boolean | null;
|
||||
stripe_secret_key?: string | null;
|
||||
stripe_webhook_secret?: string | null;
|
||||
automatic_tax?: boolean | null;
|
||||
tax_id_collection?: boolean | null;
|
||||
terms_consent_required?: boolean | null;
|
||||
default_currency?: string | null;
|
||||
prices?: Record<string, InstanceBillingPriceSetPatch> | null;
|
||||
country_currencies?: Record<string, string> | null;
|
||||
legacy_prices?: Record<string, Array<string>> | null;
|
||||
}
|
||||
|
||||
export interface InstanceBillingAdminConfig {
|
||||
enabled: boolean | null;
|
||||
effective_enabled: boolean;
|
||||
stripe_secret_key_set: boolean;
|
||||
stripe_webhook_secret_set: boolean;
|
||||
stripe_secret_key_stored: boolean;
|
||||
stripe_webhook_secret_stored: boolean;
|
||||
automatic_tax: boolean | null;
|
||||
tax_id_collection: boolean | null;
|
||||
terms_consent_required: boolean | null;
|
||||
effective_automatic_tax: boolean;
|
||||
effective_tax_id_collection: boolean;
|
||||
effective_terms_consent_required: boolean;
|
||||
default_currency: string | null;
|
||||
prices: StoredBillingConfig['prices'];
|
||||
country_currencies: StoredBillingConfig['country_currencies'];
|
||||
legacy_prices: StoredBillingConfig['legacy_prices'];
|
||||
billing_active: boolean;
|
||||
stripe_serviceable: boolean;
|
||||
catalog_mode: BillingCatalogMode;
|
||||
webhook_url: string;
|
||||
}
|
||||
|
||||
export type InstancePremiumMode = 'mirror' | 'everyone';
|
||||
|
||||
interface LimitConfigInputs {
|
||||
@@ -356,6 +407,10 @@ function normalizeOptionalPublicString(value: string | null | undefined, fallbac
|
||||
return value === undefined ? fallback : normalizeOptionalString(value);
|
||||
}
|
||||
|
||||
export function getDefaultPremiumProductName(): string {
|
||||
return Config.instance.selfHosted ? 'Premium' : 'Plutonium';
|
||||
}
|
||||
|
||||
function getDefaultAppPublicConfig(): InstanceAppPublicConfig {
|
||||
return {
|
||||
branding: {
|
||||
@@ -368,6 +423,8 @@ function getDefaultAppPublicConfig(): InstanceAppPublicConfig {
|
||||
theme_color: normalizeOptionalString(Config.instance.branding.themeColor),
|
||||
status_page_url: normalizeOptionalString(Config.instance.branding.statusPageUrl),
|
||||
status_page_incident_history_url: normalizeOptionalString(Config.instance.branding.statusPageIncidentHistoryUrl),
|
||||
premium_product_name: getDefaultPremiumProductName(),
|
||||
premium_info_url: null,
|
||||
},
|
||||
setup: {
|
||||
configured: !Config.instance.selfHosted || Config.instance.setup.configured,
|
||||
@@ -385,7 +442,6 @@ function getDefaultAppPublicConfig(): InstanceAppPublicConfig {
|
||||
type StoredConfigSection =
|
||||
| 'app public'
|
||||
| 'gateway rollout'
|
||||
| 'voice noise suppression'
|
||||
| 'push relay'
|
||||
| 'domain migration'
|
||||
| 'altcha captcha'
|
||||
@@ -394,6 +450,7 @@ type StoredConfigSection =
|
||||
| 'instance policy'
|
||||
| 'integrations'
|
||||
| 'media'
|
||||
| 'billing'
|
||||
| 'registration'
|
||||
| 'registration URLs'
|
||||
| 'pending registrations'
|
||||
@@ -524,10 +581,6 @@ function parseStoredGatewayRolloutConfig(raw: string | null): GatewayRolloutConf
|
||||
return decodeGatewayRolloutConfig(parseStoredConfigValue(raw, 'gateway rollout'));
|
||||
}
|
||||
|
||||
function parseStoredVoiceNoiseSuppressionConfig(raw: string | null): VoiceNoiseSuppressionConfig {
|
||||
return parseStoredConfigOrDefault(VoiceNoiseSuppressionConfigSchema, raw, 'voice noise suppression');
|
||||
}
|
||||
|
||||
const StoredPushRelayConfigSchema = PushRelayConfigSchema.extend({
|
||||
config_version: z.number().int().min(0).default(0),
|
||||
});
|
||||
@@ -577,7 +630,10 @@ function parseStoredCollection<T>(schema: z.ZodType<T>, raw: string | null, sect
|
||||
}
|
||||
|
||||
const StoredInstanceAppPublicSchema = InstanceAppPublicSchema.extend({
|
||||
branding: InstanceAppPublicSchema.shape.branding.partial().optional(),
|
||||
branding: InstanceAppPublicSchema.shape.branding
|
||||
.extend({premium_product_name: z.string().max(40).nullable()})
|
||||
.partial()
|
||||
.optional(),
|
||||
setup: InstanceAppPublicSchema.shape.setup.pick({configured: true}).partial().optional(),
|
||||
legal: InstanceAppPublicSchema.shape.legal.partial().optional(),
|
||||
registration: InstanceAppPublicSchema.shape.registration.partial().optional(),
|
||||
@@ -589,10 +645,6 @@ function parseStoredAppPublicConfig(raw: string | null): InstanceAppPublicConfig
|
||||
);
|
||||
}
|
||||
|
||||
function decodeAppPublicConfig(value: unknown): InstanceAppPublicConfig {
|
||||
return buildAppPublicConfig(validateStoredConfig(StoredInstanceAppPublicSchema, value, 'app public'));
|
||||
}
|
||||
|
||||
function buildAppPublicConfig(config: z.infer<typeof StoredInstanceAppPublicSchema>): InstanceAppPublicConfig {
|
||||
const defaults = getDefaultAppPublicConfig();
|
||||
const {branding = {}, setup = {}, legal = {}, registration = {}} = config;
|
||||
@@ -610,6 +662,9 @@ function buildAppPublicConfig(config: z.infer<typeof StoredInstanceAppPublicSche
|
||||
branding.status_page_incident_history_url,
|
||||
defaults.branding.status_page_incident_history_url,
|
||||
),
|
||||
premium_product_name:
|
||||
normalizeOptionalString(branding.premium_product_name) ?? defaults.branding.premium_product_name,
|
||||
premium_info_url: normalizeOptionalPublicString(branding.premium_info_url, defaults.branding.premium_info_url),
|
||||
},
|
||||
setup: {
|
||||
configured: setup.configured ?? defaults.setup.configured,
|
||||
@@ -733,6 +788,30 @@ function parseStoredInstanceIntegrationsConfig(raw: string | null): InstanceInte
|
||||
);
|
||||
}
|
||||
|
||||
function decodeInstanceBillingConfig(value: unknown): InstanceBillingConfig {
|
||||
return validateStoredConfig(StoredBillingConfigSchema, value, 'billing');
|
||||
}
|
||||
|
||||
function parseStoredInstanceBillingConfig(raw: string | null): InstanceBillingConfig {
|
||||
return salvageStoredConfig(StoredBillingConfigSchema, readStoredConfigValue(raw, 'billing'), 'billing');
|
||||
}
|
||||
|
||||
function normalizeBillingPrices(
|
||||
prices: Record<string, InstanceBillingPriceSetPatch> | null,
|
||||
): Record<string, InstanceBillingPriceSetPatch> | null {
|
||||
if (prices === null) return null;
|
||||
const entries = Object.entries(prices).map(([currency, set]): [string, InstanceBillingPriceSetPatch] => [
|
||||
currency,
|
||||
{
|
||||
monthly: set.monthly ?? null,
|
||||
yearly: set.yearly ?? null,
|
||||
gift_1_month: set.gift_1_month ?? null,
|
||||
gift_1_year: set.gift_1_year ?? null,
|
||||
},
|
||||
]);
|
||||
return entries.length === 0 ? null : Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function secretIsSet(value: unknown): boolean {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
@@ -1207,7 +1286,6 @@ export class InstanceConfigRepository {
|
||||
checkStoredConfig('gateway rollout', () =>
|
||||
parseStoredGatewayRolloutConfig(snapshot.get(GATEWAY_ROLLOUT_CONFIG_KEY) ?? null),
|
||||
);
|
||||
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
|
||||
parseStoredPushRelayConfig(snapshot.get(PUSH_RELAY_CONFIG_KEY) ?? null);
|
||||
parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null);
|
||||
parseStoredAltchaCaptchaConfig(snapshot.get(ALTCHA_CAPTCHA_CONFIG_KEY) ?? null);
|
||||
@@ -1239,6 +1317,7 @@ export class InstanceConfigRepository {
|
||||
parseStoredInstanceIntegrationsConfig(snapshot.get(INSTANCE_INTEGRATIONS_CONFIG_KEY) ?? null),
|
||||
);
|
||||
checkStoredConfig('media', () => parseStoredInstanceMediaConfig(snapshot.get(INSTANCE_MEDIA_CONFIG_KEY) ?? null));
|
||||
setStoredBillingConfig(parseStoredInstanceBillingConfig(snapshot.get(INSTANCE_BILLING_CONFIG_KEY) ?? null));
|
||||
const appPublic = parseStoredAppPublicConfig(snapshot.get(APP_PUBLIC_CONFIG_KEY) ?? null);
|
||||
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(policy));
|
||||
setCachedDateOfBirthCollection(appPublic.registration.collect_date_of_birth);
|
||||
@@ -1287,27 +1366,6 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async getVoiceNoiseSuppressionConfig(): Promise<VoiceNoiseSuppressionConfig> {
|
||||
const raw = await this.getConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY);
|
||||
return parseStoredVoiceNoiseSuppressionConfig(raw);
|
||||
}
|
||||
|
||||
async setVoiceNoiseSuppressionConfig(config: VoiceNoiseSuppressionConfig): Promise<void> {
|
||||
await this.updateVoiceNoiseSuppressionConfig(() => config);
|
||||
}
|
||||
|
||||
updateVoiceNoiseSuppressionConfig(
|
||||
update: (current: VoiceNoiseSuppressionConfig) => VoiceNoiseSuppressionConfig,
|
||||
): Promise<VoiceNoiseSuppressionConfig> {
|
||||
return this.updateStoredConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(
|
||||
VoiceNoiseSuppressionConfigSchema,
|
||||
update(parseStoredVoiceNoiseSuppressionConfig(raw)),
|
||||
'voice noise suppression',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getLegacyPushServiceDeliveryWire(): Promise<LegacyPushServiceDeliveryWire> {
|
||||
const raw = await this.getConfig(PUSH_RELAY_CONFIG_KEY);
|
||||
return parseStoredPushRelayConfig(raw);
|
||||
@@ -1433,32 +1491,49 @@ export class InstanceConfigRepository {
|
||||
}
|
||||
|
||||
async setAppPublicConfig(config: {
|
||||
branding?: Partial<InstanceBranding>;
|
||||
branding?: InstanceBrandingPatch;
|
||||
setup?: Partial<InstanceAppPublicConfig['setup']>;
|
||||
legal?: Partial<InstanceAppPublicConfig['legal']>;
|
||||
registration?: Partial<InstanceAppPublicConfig['registration']>;
|
||||
}): Promise<InstanceAppPublicConfig> {
|
||||
const next = await this.updateStoredConfig(APP_PUBLIC_CONFIG_KEY, (raw) => {
|
||||
const current = parseStoredAppPublicConfig(raw);
|
||||
return decodeAppPublicConfig({
|
||||
branding: {
|
||||
...current.branding,
|
||||
...(config.branding ?? {}),
|
||||
const cache = this.configCache;
|
||||
const {result: next} = await this.compareAndSetStoredValue(cache, APP_PUBLIC_CONFIG_KEY, (raw) => {
|
||||
const stored = salvageStoredConfig(
|
||||
StoredInstanceAppPublicSchema,
|
||||
readStoredConfigValue(raw, 'app public'),
|
||||
'app public',
|
||||
);
|
||||
const current = buildAppPublicConfig(stored);
|
||||
const premiumProductName =
|
||||
config.branding?.premium_product_name !== undefined
|
||||
? config.branding.premium_product_name
|
||||
: normalizeOptionalString(stored.branding?.premium_product_name);
|
||||
const merged = validateStoredConfig(
|
||||
StoredInstanceAppPublicSchema,
|
||||
{
|
||||
branding: {
|
||||
...current.branding,
|
||||
...(config.branding ?? {}),
|
||||
premium_product_name: premiumProductName,
|
||||
},
|
||||
setup: {
|
||||
...current.setup,
|
||||
...(config.setup ?? {}),
|
||||
},
|
||||
legal: {
|
||||
...current.legal,
|
||||
...(config.legal ?? {}),
|
||||
},
|
||||
registration: {
|
||||
...current.registration,
|
||||
...(config.registration ?? {}),
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
...current.setup,
|
||||
...(config.setup ?? {}),
|
||||
},
|
||||
legal: {
|
||||
...current.legal,
|
||||
...(config.legal ?? {}),
|
||||
},
|
||||
registration: {
|
||||
...current.registration,
|
||||
...(config.registration ?? {}),
|
||||
},
|
||||
});
|
||||
'app public',
|
||||
);
|
||||
return {value: JSON.stringify(merged), result: buildAppPublicConfig(merged)};
|
||||
});
|
||||
await this.publishRefresh(cache.sourceId);
|
||||
setCachedDateOfBirthCollection(next.registration.collect_date_of_birth);
|
||||
return next;
|
||||
}
|
||||
@@ -1758,6 +1833,71 @@ export class InstanceConfigRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async getInstanceBillingConfig(): Promise<InstanceBillingConfig> {
|
||||
const raw = await this.getConfig(INSTANCE_BILLING_CONFIG_KEY);
|
||||
const config = parseStoredInstanceBillingConfig(raw);
|
||||
setStoredBillingConfig(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
async readStoredInstanceBillingConfig(): Promise<InstanceBillingConfig> {
|
||||
const cache = this.configCache;
|
||||
cache.assertActive();
|
||||
const raw = await this.fetchConfigFromDatabase(INSTANCE_BILLING_CONFIG_KEY);
|
||||
cache.assertActive();
|
||||
return parseStoredInstanceBillingConfig(raw);
|
||||
}
|
||||
|
||||
async setInstanceBillingConfig(patch: InstanceBillingConfigPatch): Promise<InstanceBillingConfig> {
|
||||
const next = await this.updateStoredConfig(INSTANCE_BILLING_CONFIG_KEY, (raw) => {
|
||||
const current = parseStoredInstanceBillingConfig(raw);
|
||||
return decodeInstanceBillingConfig({
|
||||
enabled: patch.enabled === undefined ? current.enabled : patch.enabled,
|
||||
stripe_secret_key: patch.stripe_secret_key === undefined ? current.stripe_secret_key : patch.stripe_secret_key,
|
||||
stripe_webhook_secret:
|
||||
patch.stripe_webhook_secret === undefined ? current.stripe_webhook_secret : patch.stripe_webhook_secret,
|
||||
automatic_tax: patch.automatic_tax === undefined ? current.automatic_tax : patch.automatic_tax,
|
||||
tax_id_collection: patch.tax_id_collection === undefined ? current.tax_id_collection : patch.tax_id_collection,
|
||||
terms_consent_required:
|
||||
patch.terms_consent_required === undefined ? current.terms_consent_required : patch.terms_consent_required,
|
||||
default_currency: patch.default_currency === undefined ? current.default_currency : patch.default_currency,
|
||||
prices: patch.prices === undefined ? current.prices : normalizeBillingPrices(patch.prices),
|
||||
country_currencies:
|
||||
patch.country_currencies === undefined ? current.country_currencies : patch.country_currencies,
|
||||
legacy_prices: patch.legacy_prices === undefined ? current.legacy_prices : patch.legacy_prices,
|
||||
});
|
||||
});
|
||||
setStoredBillingConfig(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
async getInstanceBillingAdminConfig(): Promise<InstanceBillingAdminConfig> {
|
||||
const stored = await this.getInstanceBillingConfig();
|
||||
const effective = getEffectiveBillingConfig();
|
||||
return {
|
||||
enabled: stored.enabled,
|
||||
effective_enabled: effective.enabled,
|
||||
stripe_secret_key_set: effective.secretKey !== null,
|
||||
stripe_webhook_secret_set: effective.webhookSecret !== null,
|
||||
stripe_secret_key_stored: secretIsSet(stored.stripe_secret_key),
|
||||
stripe_webhook_secret_stored: secretIsSet(stored.stripe_webhook_secret),
|
||||
automatic_tax: stored.automatic_tax,
|
||||
tax_id_collection: stored.tax_id_collection,
|
||||
terms_consent_required: stored.terms_consent_required,
|
||||
effective_automatic_tax: effective.automaticTax,
|
||||
effective_tax_id_collection: effective.taxIdCollection,
|
||||
effective_terms_consent_required: effective.termsConsentRequired,
|
||||
default_currency: stored.default_currency,
|
||||
prices: stored.prices,
|
||||
country_currencies: stored.country_currencies,
|
||||
legacy_prices: stored.legacy_prices,
|
||||
billing_active: isBillingActive(effective),
|
||||
stripe_serviceable: isStripeServiceable(effective),
|
||||
catalog_mode: effective.catalogMode,
|
||||
webhook_url: `${Config.endpoints.apiPublic.replace(/\/+$/, '')}/stripe/webhook`,
|
||||
};
|
||||
}
|
||||
|
||||
async getInstanceCommunityPublicConfig(): Promise<InstanceCommunity> {
|
||||
const policy = await this.getInstancePolicyConfig();
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
|
||||
import {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
|
||||
import {
|
||||
getEffectiveBillingConfig,
|
||||
getStoredBillingConfig,
|
||||
setStoredBillingConfig,
|
||||
} from '@app/api/stripe/BillingConfigCache';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const INSTANCE_BILLING_CONFIG_KEY = 'instance_billing_config';
|
||||
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
|
||||
|
||||
const GBP_PRICES = {
|
||||
monthly: 'price_GbpMonthly',
|
||||
yearly: 'price_GbpYearly',
|
||||
gift_1_month: 'price_GbpGiftMonth',
|
||||
gift_1_year: 'price_GbpGiftYear',
|
||||
};
|
||||
|
||||
describe('InstanceConfigRepository billing and premium branding', () => {
|
||||
const repositories: Array<InstanceConfigRepository> = [];
|
||||
const originalSelfHosted = Config.instance.selfHosted;
|
||||
let originalStored = getStoredBillingConfig();
|
||||
let originalPremiumMode = getCachedInstancePremiumMode();
|
||||
let executor: InMemoryCassandraQueryExecutor;
|
||||
|
||||
beforeEach(() => {
|
||||
originalStored = getStoredBillingConfig();
|
||||
originalPremiumMode = getCachedInstancePremiumMode();
|
||||
executor = new InMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
Config.instance.selfHosted = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const repository of repositories) {
|
||||
repository.shutdown();
|
||||
}
|
||||
repositories.length = 0;
|
||||
Config.instance.selfHosted = originalSelfHosted;
|
||||
setCachedInstancePremiumMode(originalPremiumMode);
|
||||
setStoredBillingConfig(originalStored);
|
||||
});
|
||||
|
||||
function createRepository(kvProvider = new MockKVProvider()): InstanceConfigRepository {
|
||||
const repository = new InstanceConfigRepository(kvProvider);
|
||||
repositories.push(repository);
|
||||
return repository;
|
||||
}
|
||||
|
||||
async function readRaw(repository: InstanceConfigRepository, key: string): Promise<unknown> {
|
||||
const raw = await repository.getConfig(key);
|
||||
return raw === null ? null : JSON.parse(raw);
|
||||
}
|
||||
|
||||
it('returns an all-null billing config when nothing is stored', async () => {
|
||||
const repository = createRepository();
|
||||
expect(await repository.getInstanceBillingConfig()).toEqual({
|
||||
enabled: null,
|
||||
stripe_secret_key: null,
|
||||
stripe_webhook_secret: null,
|
||||
automatic_tax: null,
|
||||
tax_id_collection: null,
|
||||
terms_consent_required: null,
|
||||
default_currency: null,
|
||||
prices: null,
|
||||
country_currencies: null,
|
||||
legacy_prices: null,
|
||||
});
|
||||
expect(getEffectiveBillingConfig().catalogMode).toBe('env');
|
||||
});
|
||||
|
||||
it('merges patches, keeps secrets on undefined, clears them on null and replaces the catalog maps', async () => {
|
||||
const repository = createRepository();
|
||||
await repository.setInstanceBillingConfig({
|
||||
enabled: true,
|
||||
stripe_secret_key: 'sk_test_operator',
|
||||
stripe_webhook_secret: 'whsec_operator',
|
||||
default_currency: 'GBP',
|
||||
prices: {GBP: GBP_PRICES, CHF: {monthly: 'price_ChfMonthly', yearly: 'price_ChfYearly'}},
|
||||
country_currencies: {CH: 'CHF'},
|
||||
legacy_prices: {monthly_GBP: ['price_GbpOld']},
|
||||
});
|
||||
|
||||
const merged = await repository.setInstanceBillingConfig({
|
||||
default_currency: 'CHF',
|
||||
prices: {GBP: GBP_PRICES},
|
||||
country_currencies: {LI: 'CHF'},
|
||||
});
|
||||
expect(merged).toEqual({
|
||||
enabled: true,
|
||||
stripe_secret_key: 'sk_test_operator',
|
||||
stripe_webhook_secret: 'whsec_operator',
|
||||
automatic_tax: null,
|
||||
tax_id_collection: null,
|
||||
terms_consent_required: null,
|
||||
default_currency: 'CHF',
|
||||
prices: {GBP: GBP_PRICES},
|
||||
country_currencies: {LI: 'CHF'},
|
||||
legacy_prices: {monthly_GBP: ['price_GbpOld']},
|
||||
});
|
||||
expect(getStoredBillingConfig()).toEqual(merged);
|
||||
expect(getEffectiveBillingConfig().catalogMode).toBe('operator');
|
||||
|
||||
const cleared = await repository.setInstanceBillingConfig({
|
||||
stripe_webhook_secret: null,
|
||||
legacy_prices: null,
|
||||
});
|
||||
expect(cleared.stripe_secret_key).toBe('sk_test_operator');
|
||||
expect(cleared.stripe_webhook_secret).toBeNull();
|
||||
expect(cleared.legacy_prices).toBeNull();
|
||||
expect(await readRaw(repository, INSTANCE_BILLING_CONFIG_KEY)).toEqual(cleared);
|
||||
});
|
||||
|
||||
it('fills missing price slots with null and treats an empty price map as the env catalog', async () => {
|
||||
const repository = createRepository();
|
||||
const partial = await repository.setInstanceBillingConfig({prices: {GBP: {monthly: 'price_GbpMonthly'}}});
|
||||
expect(partial.prices).toEqual({
|
||||
GBP: {monthly: 'price_GbpMonthly', yearly: null, gift_1_month: null, gift_1_year: null},
|
||||
});
|
||||
const emptied = await repository.setInstanceBillingConfig({prices: {}});
|
||||
expect(emptied.prices).toBeNull();
|
||||
expect(getEffectiveBillingConfig().catalogMode).toBe('env');
|
||||
});
|
||||
|
||||
it('rejects a patch that would store an invalid value', async () => {
|
||||
const repository = createRepository();
|
||||
await expect(repository.setInstanceBillingConfig({default_currency: 'gbp'})).rejects.toThrow(/billing/);
|
||||
await expect(repository.setInstanceBillingConfig({prices: {GBP: {monthly: 'not-a-price-id'}}})).rejects.toThrow(
|
||||
/billing/,
|
||||
);
|
||||
expect(await repository.getConfig(INSTANCE_BILLING_CONFIG_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('redacts secrets in the admin view and reports the effective state', async () => {
|
||||
Config.instance.selfHosted = true;
|
||||
setCachedInstancePremiumMode('mirror');
|
||||
const repository = createRepository();
|
||||
await repository.setInstanceBillingConfig({
|
||||
enabled: true,
|
||||
stripe_secret_key: 'sk_test_do_not_leak',
|
||||
stripe_webhook_secret: 'whsec_do_not_leak',
|
||||
prices: {GBP: GBP_PRICES},
|
||||
});
|
||||
|
||||
const admin = await repository.getInstanceBillingAdminConfig();
|
||||
expect(JSON.stringify(admin)).not.toContain('do_not_leak');
|
||||
expect(admin).toEqual({
|
||||
enabled: true,
|
||||
effective_enabled: true,
|
||||
stripe_secret_key_set: true,
|
||||
stripe_webhook_secret_set: true,
|
||||
stripe_secret_key_stored: true,
|
||||
stripe_webhook_secret_stored: true,
|
||||
automatic_tax: null,
|
||||
tax_id_collection: null,
|
||||
terms_consent_required: null,
|
||||
effective_automatic_tax: false,
|
||||
effective_tax_id_collection: false,
|
||||
effective_terms_consent_required: false,
|
||||
default_currency: null,
|
||||
prices: {GBP: GBP_PRICES},
|
||||
country_currencies: null,
|
||||
legacy_prices: null,
|
||||
billing_active: true,
|
||||
stripe_serviceable: true,
|
||||
catalog_mode: 'operator',
|
||||
webhook_url: `${Config.endpoints.apiPublic.replace(/\/+$/, '')}/stripe/webhook`,
|
||||
});
|
||||
|
||||
setCachedInstancePremiumMode('everyone');
|
||||
const everyone = await repository.getInstanceBillingAdminConfig();
|
||||
expect(everyone.billing_active).toBe(false);
|
||||
expect(everyone.stripe_serviceable).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps, sets and clears the checkout flags like the other nullable fields', async () => {
|
||||
const repository = createRepository();
|
||||
const set = await repository.setInstanceBillingConfig({automatic_tax: true, terms_consent_required: false});
|
||||
expect(set.automatic_tax).toBe(true);
|
||||
expect(set.tax_id_collection).toBeNull();
|
||||
expect(set.terms_consent_required).toBe(false);
|
||||
const kept = await repository.setInstanceBillingConfig({tax_id_collection: true});
|
||||
expect(kept.automatic_tax).toBe(true);
|
||||
expect(kept.tax_id_collection).toBe(true);
|
||||
expect(kept.terms_consent_required).toBe(false);
|
||||
const cleared = await repository.setInstanceBillingConfig({automatic_tax: null});
|
||||
expect(cleared.automatic_tax).toBeNull();
|
||||
const admin = await repository.getInstanceBillingAdminConfig();
|
||||
expect(admin.automatic_tax).toBeNull();
|
||||
expect(admin.tax_id_collection).toBe(true);
|
||||
expect(admin.terms_consent_required).toBe(false);
|
||||
expect(admin.effective_automatic_tax).toBe(false);
|
||||
expect(admin.effective_tax_id_collection).toBe(true);
|
||||
expect(admin.effective_terms_consent_required).toBe(false);
|
||||
});
|
||||
|
||||
it('reports env-sourced secrets as set but not stored', async () => {
|
||||
const repository = createRepository();
|
||||
const admin = await repository.getInstanceBillingAdminConfig();
|
||||
expect(admin.stripe_secret_key_set).toBe(Boolean(Config.stripe.secretKey));
|
||||
expect(admin.stripe_webhook_secret_set).toBe(Boolean(Config.stripe.webhookSecret));
|
||||
expect(admin.stripe_secret_key_stored).toBe(false);
|
||||
expect(admin.stripe_webhook_secret_stored).toBe(false);
|
||||
await repository.setInstanceBillingConfig({stripe_webhook_secret: 'whsec_stored_only'});
|
||||
const stored = await repository.getInstanceBillingAdminConfig();
|
||||
expect(stored.stripe_secret_key_stored).toBe(false);
|
||||
expect(stored.stripe_webhook_secret_stored).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores a stored billing config on hosted', async () => {
|
||||
Config.instance.selfHosted = false;
|
||||
const repository = createRepository();
|
||||
await repository.setInstanceBillingConfig({
|
||||
enabled: false,
|
||||
stripe_secret_key: 'sk_test_stored',
|
||||
prices: {GBP: GBP_PRICES},
|
||||
automatic_tax: false,
|
||||
tax_id_collection: false,
|
||||
terms_consent_required: false,
|
||||
});
|
||||
const effective = getEffectiveBillingConfig();
|
||||
expect(effective.catalogMode).toBe('env');
|
||||
expect(effective.enabled).toBe(Config.stripe.enabled);
|
||||
expect(effective.secretKey).toBe(Config.stripe.secretKey || null);
|
||||
const admin = await repository.getInstanceBillingAdminConfig();
|
||||
expect(admin.catalog_mode).toBe('env');
|
||||
expect(admin.effective_automatic_tax).toBe(true);
|
||||
expect(admin.effective_tax_id_collection).toBe(true);
|
||||
expect(admin.effective_terms_consent_required).toBe(true);
|
||||
});
|
||||
|
||||
it('salvages the valid fields of a partly invalid stored billing config', async () => {
|
||||
const repository = createRepository();
|
||||
await repository.setConfig(
|
||||
INSTANCE_BILLING_CONFIG_KEY,
|
||||
JSON.stringify({
|
||||
enabled: 'yes',
|
||||
stripe_secret_key: 'sk_test_kept',
|
||||
default_currency: 'GBP',
|
||||
prices: {
|
||||
GBP: GBP_PRICES,
|
||||
gbp: GBP_PRICES,
|
||||
EUR: {...GBP_PRICES, monthly: 'bogus'},
|
||||
},
|
||||
country_currencies: {GB: 'GBP', gb: 'GBP'},
|
||||
}),
|
||||
);
|
||||
|
||||
const config = await repository.getInstanceBillingConfig();
|
||||
expect(config.enabled).toBeNull();
|
||||
expect(config.stripe_secret_key).toBe('sk_test_kept');
|
||||
expect(config.default_currency).toBe('GBP');
|
||||
expect(config.prices?.GBP).toEqual(GBP_PRICES);
|
||||
expect(config.prices).not.toHaveProperty('gbp');
|
||||
expect(config.prices?.EUR?.monthly ?? null).toBeNull();
|
||||
expect(config.country_currencies).toEqual({GB: 'GBP'});
|
||||
});
|
||||
|
||||
it('falls back to an empty billing config when the stored value is not JSON', async () => {
|
||||
const repository = createRepository();
|
||||
await repository.setConfig(INSTANCE_BILLING_CONFIG_KEY, '{not json');
|
||||
expect((await repository.getInstanceBillingConfig()).prices).toBeNull();
|
||||
});
|
||||
|
||||
it('updates the process billing cache when another repository publishes a billing change', async () => {
|
||||
const kvProvider = new MockKVProvider();
|
||||
const reader = createRepository(kvProvider);
|
||||
const writer = createRepository(kvProvider);
|
||||
await reader.getInstanceBillingConfig();
|
||||
await writer.getInstanceBillingConfig();
|
||||
setStoredBillingConfig(null);
|
||||
|
||||
await writer.setConfig(
|
||||
INSTANCE_BILLING_CONFIG_KEY,
|
||||
JSON.stringify({enabled: true, prices: {GBP: GBP_PRICES}, default_currency: 'GBP'}),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getStoredBillingConfig()?.default_currency).toBe('GBP');
|
||||
});
|
||||
expect(getEffectiveBillingConfig().catalogMode).toBe('operator');
|
||||
});
|
||||
|
||||
it('defaults the premium product name to Plutonium on hosted and Premium on self-hosted', async () => {
|
||||
const repository = createRepository();
|
||||
Config.instance.selfHosted = false;
|
||||
const hosted = await repository.getAppPublicConfig();
|
||||
expect(hosted.branding.premium_product_name).toBe('Plutonium');
|
||||
expect(hosted.branding.premium_info_url).toBeNull();
|
||||
Config.instance.selfHosted = true;
|
||||
expect((await repository.getAppPublicConfig()).branding.premium_product_name).toBe('Premium');
|
||||
});
|
||||
|
||||
it('stores the premium name nullable so a reset and unrelated branding saves keep the default', async () => {
|
||||
Config.instance.selfHosted = true;
|
||||
const repository = createRepository();
|
||||
|
||||
await repository.setAppPublicConfig({branding: {theme_color: '#123456'}});
|
||||
expect(await readRaw(repository, APP_PUBLIC_CONFIG_KEY)).toMatchObject({
|
||||
branding: {theme_color: '#123456', premium_product_name: null},
|
||||
});
|
||||
|
||||
const named = await repository.setAppPublicConfig({
|
||||
branding: {premium_product_name: 'Gold', premium_info_url: 'https://example.com/gold'},
|
||||
});
|
||||
expect(named.branding.premium_product_name).toBe('Gold');
|
||||
expect(named.branding.premium_info_url).toBe('https://example.com/gold');
|
||||
|
||||
const unrelated = await repository.setAppPublicConfig({branding: {product_name: 'Example'}});
|
||||
expect(unrelated.branding.premium_product_name).toBe('Gold');
|
||||
expect(unrelated.branding.premium_info_url).toBe('https://example.com/gold');
|
||||
|
||||
const reset = await repository.setAppPublicConfig({branding: {premium_product_name: null, premium_info_url: null}});
|
||||
expect(reset.branding.premium_product_name).toBe('Premium');
|
||||
expect(reset.branding.premium_info_url).toBeNull();
|
||||
expect(await readRaw(repository, APP_PUBLIC_CONFIG_KEY)).toMatchObject({
|
||||
branding: {product_name: 'Example', premium_product_name: null, premium_info_url: null},
|
||||
});
|
||||
|
||||
Config.instance.selfHosted = false;
|
||||
expect((await repository.getAppPublicConfig()).branding.premium_product_name).toBe('Plutonium');
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import type {InstanceCaptchaEffectiveConfig} from '@app/api/instance/InstanceCon
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import {isBillingActive, isPremiumTieringActive, isStripeServiceable} from '@app/api/stripe/BillingConfigCache';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {API_CODE_VERSION} from '@fluxer/constants/src/AppConstants';
|
||||
import {buildDiscoveryResponse, type DiscoveryStaticInput} from '@fluxer/instance_bootstrap/src/BuildDiscovery';
|
||||
@@ -66,7 +67,9 @@ function buildDiscoveryStaticInput(
|
||||
},
|
||||
features: {
|
||||
voice_enabled: Config.voice.enabled,
|
||||
stripe_enabled: Config.stripe.enabled,
|
||||
stripe_enabled: isBillingActive(),
|
||||
premium_enabled: isPremiumTieringActive(),
|
||||
stripe_serviceable: isStripeServiceable(),
|
||||
self_hosted: Config.instance.selfHosted,
|
||||
presigned_attachment_uploads: Config.presignedAttachmentUploadsEnabled,
|
||||
emails_enabled: runtime.emailEnabled,
|
||||
|
||||
@@ -216,7 +216,7 @@ export class UserSettings {
|
||||
friend_source_flags: friendSourceFlags,
|
||||
incoming_call_flags: IncomingCallFlags.FRIENDS_ONLY,
|
||||
group_dm_add_permission_flags: GroupDmAddPermissionFlags.FRIENDS_ONLY,
|
||||
default_guilds_restricted: false,
|
||||
default_guilds_restricted: true,
|
||||
bot_default_guilds_restricted: false,
|
||||
restricted_guilds: new Set(),
|
||||
bot_restricted_guilds: new Set(),
|
||||
|
||||
@@ -3030,7 +3030,8 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Clears all read state and acknowledgement records for a channel, marking all messages as unread. Returns 204 No Content on success.",
|
||||
"description": "Deprecated. Has no effect on the read state. Returns 204 No Content.",
|
||||
"deprecated": true,
|
||||
"security": [{"botToken": []}, {"sessionToken": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -26130,7 +26131,10 @@
|
||||
"anyOf": [{"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}, {"type": "null"}]
|
||||
},
|
||||
"currency": {"description": "Currency for the prices", "$ref": "#/components/schemas/PremiumCurrency"},
|
||||
"gift_currency": {"description": "Currency for gift prices", "$ref": "#/components/schemas/PremiumCurrency"}
|
||||
"gift_currency": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/PremiumCurrency"}, {"type": "null"}],
|
||||
"description": "Currency for gift prices, null when no gift prices are configured"
|
||||
}
|
||||
},
|
||||
"required": ["currency", "gift_currency"],
|
||||
"additionalProperties": false
|
||||
@@ -27946,7 +27950,6 @@
|
||||
"assignments": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionAssignmentResponse"},
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"},
|
||||
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaAssignmentResponse"},
|
||||
"profile_timezone": {"$ref": "#/components/schemas/ProfileTimezoneAssignmentResponse"}
|
||||
@@ -29757,6 +29760,14 @@
|
||||
"status_page_incident_history_url": {
|
||||
"description": "Optional public status page incident history URL",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"premium_product_name": {
|
||||
"type": "string",
|
||||
"description": "Name of the premium tier shown by client applications"
|
||||
},
|
||||
"premium_info_url": {
|
||||
"description": "Optional absolute URL of a page describing the premium tier",
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -29768,7 +29779,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,
|
||||
"description": "Branding values safe to expose to clients"
|
||||
@@ -29865,7 +29878,18 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"voice_enabled": {"type": "boolean", "description": "Whether voice/video calling is enabled"},
|
||||
"stripe_enabled": {"type": "boolean", "description": "Whether Stripe payments are enabled"},
|
||||
"stripe_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether premium purchases through Stripe are available"
|
||||
},
|
||||
"premium_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this instance has a premium tier, so premium status, gifts and perks apply"
|
||||
},
|
||||
"stripe_serviceable": {
|
||||
"type": "boolean",
|
||||
"description": "Whether existing Stripe subscriptions can be managed, cancelled and billed on this instance"
|
||||
},
|
||||
"self_hosted": {"type": "boolean", "description": "Whether this is a self-hosted instance"},
|
||||
"presigned_attachment_uploads": {
|
||||
"type": "boolean",
|
||||
@@ -29879,6 +29903,8 @@
|
||||
"required": [
|
||||
"voice_enabled",
|
||||
"stripe_enabled",
|
||||
"premium_enabled",
|
||||
"stripe_serviceable",
|
||||
"self_hosted",
|
||||
"presigned_attachment_uploads",
|
||||
"emails_enabled"
|
||||
@@ -31643,50 +31669,6 @@
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VoiceNoiseSuppressionAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"config_version": {"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991},
|
||||
"user_targeted": {"type": "boolean"},
|
||||
"backend": {"anyOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}, {"type": "null"}]},
|
||||
"source": {"anyOf": [{"type": "string", "enum": ["user_rule", "canary"]}, {"type": "null"}]},
|
||||
"guild_overrides": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"guild_id": {"type": "string", "pattern": "^\\d{1,20}$"},
|
||||
"backend": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
|
||||
},
|
||||
"required": ["guild_id", "backend"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"enabled_backends": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
|
||||
},
|
||||
"allow_user_override": {"type": "boolean"},
|
||||
"suppression_strength": {"type": "integer", "minimum": 0, "maximum": 100}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"user_targeted",
|
||||
"backend",
|
||||
"source",
|
||||
"guild_overrides",
|
||||
"enabled_backends",
|
||||
"allow_user_override",
|
||||
"suppression_strength"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VoiceNoiseSuppressionBackendSchema": {
|
||||
"type": "string",
|
||||
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"]
|
||||
},
|
||||
"GiftCodeDurationTypeSchema": {
|
||||
"description": "Gift code duration unit",
|
||||
"x-enumNames": ["days", "weeks", "months", "years"],
|
||||
@@ -32896,7 +32878,7 @@
|
||||
"enum": ["access_token", "refresh_token"],
|
||||
"type": "string"
|
||||
},
|
||||
"PremiumCurrency": {"type": "string", "enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"]},
|
||||
"PremiumCurrency": {"type": "string", "pattern": "^[A-Z]{3}$"},
|
||||
"SelfServeRefundIneligibilityReason": {
|
||||
"type": "string",
|
||||
"enum": ["no_refundable_purchase", "outside_refund_window", "cooldown_active", "feature_unavailable"]
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createChannel, createGuild} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {sendMessage} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface AckResponse {
|
||||
read_states: Array<{
|
||||
id: string;
|
||||
last_message_id: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
describe('DELETE /channels/:channel_id/messages/ack', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
test('leaves the read state untouched', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Read State Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'read-state-channel');
|
||||
const older = await sendMessage(harness, account.token, channel.id, 'older');
|
||||
const newer = await sendMessage(harness, account.token, channel.id, 'newer');
|
||||
await createBuilder<AckResponse>(harness, account.token)
|
||||
.post('/read-states/ack')
|
||||
.body({read_states: [{channel_id: channel.id, message_id: newer.id}]})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/channels/${channel.id}/messages/ack`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
const response = await createBuilder<AckResponse>(harness, account.token)
|
||||
.post('/read-states/ack')
|
||||
.body({read_states: [{channel_id: channel.id, message_id: older.id}]})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(response.read_states[0]?.last_message_id).toBe(newer.id);
|
||||
});
|
||||
});
|
||||
@@ -35,8 +35,6 @@ export abstract class IReadStateRepository {
|
||||
}>
|
||||
>;
|
||||
|
||||
abstract deleteReadState(userId: UserID, channelId: ChannelID): Promise<void>;
|
||||
|
||||
abstract bulkAckMessages(
|
||||
userId: UserID,
|
||||
readStates: Array<{
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
|
||||
import {channelIdToMessageId} from '@app/api/BrandedTypes';
|
||||
import {
|
||||
BatchBuilder,
|
||||
deleteOneOrMany,
|
||||
fetchMany,
|
||||
fetchManyInChunks,
|
||||
fetchOne,
|
||||
@@ -199,15 +198,6 @@ export class ReadStateRepository implements IReadStateRepository {
|
||||
return appliedUpdates;
|
||||
}
|
||||
|
||||
async deleteReadState(userId: UserID, channelId: ChannelID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
ReadStates.deleteByPk({
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async bulkAckMessages(
|
||||
userId: UserID,
|
||||
readStates: Array<{
|
||||
|
||||
@@ -141,10 +141,6 @@ export class ReadStateService {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteReadState({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
|
||||
await this.repository.deleteReadState(userId, channelId);
|
||||
}
|
||||
|
||||
async incrementMentionCount({
|
||||
userId,
|
||||
channelId,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
|
||||
export interface BillingBranding {
|
||||
productName: string;
|
||||
premiumName: string;
|
||||
termsUrl: string;
|
||||
upiMandateDescription: string;
|
||||
}
|
||||
|
||||
const HOSTED_PRODUCT_NAME = 'Fluxer';
|
||||
const HOSTED_PREMIUM_NAME = 'Plutonium';
|
||||
const HOSTED_UPI_MANDATE_DESCRIPTION = 'Fluxer Premium';
|
||||
|
||||
export async function getBillingBranding(): Promise<BillingBranding> {
|
||||
const marketingTermsUrl = `${Config.endpoints.marketing}/terms`;
|
||||
if (!Config.instance.selfHosted) {
|
||||
return {
|
||||
productName: HOSTED_PRODUCT_NAME,
|
||||
premiumName: HOSTED_PREMIUM_NAME,
|
||||
termsUrl: marketingTermsUrl,
|
||||
upiMandateDescription: HOSTED_UPI_MANDATE_DESCRIPTION,
|
||||
};
|
||||
}
|
||||
const appPublic = await getInstanceConfigRepository().getAppPublicConfig();
|
||||
const productName = appPublic.branding.product_name;
|
||||
const premiumName = appPublic.branding.premium_product_name;
|
||||
return {
|
||||
productName,
|
||||
premiumName,
|
||||
termsUrl: appPublic.legal.terms_url ?? marketingTermsUrl,
|
||||
upiMandateDescription: `${productName} ${premiumName}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {getCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
|
||||
import type {BillingCatalogMode, StoredBillingConfig} from '@fluxer/schema/src/domains/admin/InstanceBillingSchemas';
|
||||
|
||||
export type {StoredBillingConfig} from '@fluxer/schema/src/domains/admin/InstanceBillingSchemas';
|
||||
|
||||
export interface BillingPriceSet {
|
||||
monthly: string | null;
|
||||
yearly: string | null;
|
||||
gift_1_month: string | null;
|
||||
gift_1_year: string | null;
|
||||
}
|
||||
|
||||
export interface EffectiveBillingConfig {
|
||||
enabled: boolean;
|
||||
secretKey: string | null;
|
||||
webhookSecret: string | null;
|
||||
automaticTax: boolean;
|
||||
taxIdCollection: boolean;
|
||||
termsConsentRequired: boolean;
|
||||
catalogMode: BillingCatalogMode;
|
||||
defaultCurrency: string | null;
|
||||
prices: Record<string, BillingPriceSet>;
|
||||
countryCurrencies: Record<string, string>;
|
||||
legacyPrices: Record<string, Array<string>>;
|
||||
version: string;
|
||||
}
|
||||
|
||||
type EnvPrices = NonNullable<typeof Config.stripe.prices>;
|
||||
type EnvPriceKey = keyof EnvPrices;
|
||||
|
||||
const ENV_CATALOG: ReadonlyArray<{
|
||||
currency: string;
|
||||
monthly: EnvPriceKey;
|
||||
yearly: EnvPriceKey;
|
||||
gift_1_month: EnvPriceKey;
|
||||
gift_1_year: EnvPriceKey;
|
||||
}> = [
|
||||
{
|
||||
currency: 'USD',
|
||||
monthly: 'monthlyUsd',
|
||||
yearly: 'yearlyUsd',
|
||||
gift_1_month: 'gift1MonthUsd',
|
||||
gift_1_year: 'gift1YearUsd',
|
||||
},
|
||||
{
|
||||
currency: 'EUR',
|
||||
monthly: 'monthlyEur',
|
||||
yearly: 'yearlyEur',
|
||||
gift_1_month: 'gift1MonthEur',
|
||||
gift_1_year: 'gift1YearEur',
|
||||
},
|
||||
{
|
||||
currency: 'BRL',
|
||||
monthly: 'monthlyBrl',
|
||||
yearly: 'yearlyBrl',
|
||||
gift_1_month: 'gift1MonthBrl',
|
||||
gift_1_year: 'gift1YearBrl',
|
||||
},
|
||||
{
|
||||
currency: 'DKK',
|
||||
monthly: 'monthlyDkk',
|
||||
yearly: 'yearlyDkk',
|
||||
gift_1_month: 'gift1MonthDkk',
|
||||
gift_1_year: 'gift1YearDkk',
|
||||
},
|
||||
{
|
||||
currency: 'INR',
|
||||
monthly: 'monthlyInr',
|
||||
yearly: 'yearlyInr',
|
||||
gift_1_month: 'gift1MonthInr',
|
||||
gift_1_year: 'gift1YearInr',
|
||||
},
|
||||
{
|
||||
currency: 'NOK',
|
||||
monthly: 'monthlyNok',
|
||||
yearly: 'yearlyNok',
|
||||
gift_1_month: 'gift1MonthNok',
|
||||
gift_1_year: 'gift1YearNok',
|
||||
},
|
||||
{
|
||||
currency: 'PLN',
|
||||
monthly: 'monthlyPln',
|
||||
yearly: 'yearlyPln',
|
||||
gift_1_month: 'gift1MonthPln',
|
||||
gift_1_year: 'gift1YearPln',
|
||||
},
|
||||
{
|
||||
currency: 'SEK',
|
||||
monthly: 'monthlySek',
|
||||
yearly: 'yearlySek',
|
||||
gift_1_month: 'gift1MonthSek',
|
||||
gift_1_year: 'gift1YearSek',
|
||||
},
|
||||
{
|
||||
currency: 'TRY',
|
||||
monthly: 'monthlyTry',
|
||||
yearly: 'yearlyTry',
|
||||
gift_1_month: 'gift1MonthTry',
|
||||
gift_1_year: 'gift1YearTry',
|
||||
},
|
||||
];
|
||||
|
||||
const PREVIOUS_WEBHOOK_SECRET_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
let storedBillingConfig: StoredBillingConfig | null = null;
|
||||
let memoizedSource: string | null = null;
|
||||
let memoizedConfig: EffectiveBillingConfig | null = null;
|
||||
let lastEffectiveWebhookSecret: string | null | undefined;
|
||||
let previousWebhookSecrets: Array<{secret: string; expiresAt: number}> = [];
|
||||
|
||||
export function setStoredBillingConfig(stored: StoredBillingConfig | null): void {
|
||||
storedBillingConfig = stored;
|
||||
trackWebhookSecret();
|
||||
}
|
||||
|
||||
export function getStoredBillingConfig(): StoredBillingConfig | null {
|
||||
return storedBillingConfig;
|
||||
}
|
||||
|
||||
export function getActiveStoredBillingConfig(): StoredBillingConfig | null {
|
||||
return Config.instance.selfHosted ? storedBillingConfig : null;
|
||||
}
|
||||
|
||||
function trackWebhookSecret(): void {
|
||||
if (!Config.instance.selfHosted) {
|
||||
previousWebhookSecrets = [];
|
||||
lastEffectiveWebhookSecret = undefined;
|
||||
return;
|
||||
}
|
||||
const current = computeEffectiveBillingConfig().webhookSecret;
|
||||
const now = Date.now();
|
||||
previousWebhookSecrets = previousWebhookSecrets.filter((entry) => entry.expiresAt > now && entry.secret !== current);
|
||||
if (lastEffectiveWebhookSecret && lastEffectiveWebhookSecret !== current) {
|
||||
const replaced = lastEffectiveWebhookSecret;
|
||||
previousWebhookSecrets = [
|
||||
{secret: replaced, expiresAt: now + PREVIOUS_WEBHOOK_SECRET_TTL_MS},
|
||||
...previousWebhookSecrets.filter((entry) => entry.secret !== replaced),
|
||||
];
|
||||
}
|
||||
lastEffectiveWebhookSecret = current;
|
||||
}
|
||||
|
||||
export function getAcceptedWebhookSecrets(config: EffectiveBillingConfig = getEffectiveBillingConfig()): Array<string> {
|
||||
trackWebhookSecret();
|
||||
const now = Date.now();
|
||||
const secrets = config.webhookSecret ? [config.webhookSecret] : [];
|
||||
for (const entry of previousWebhookSecrets) {
|
||||
if (entry.expiresAt > now && !secrets.includes(entry.secret)) {
|
||||
secrets.push(entry.secret);
|
||||
}
|
||||
}
|
||||
return secrets;
|
||||
}
|
||||
|
||||
function normalizeId(value: string | null | undefined): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function buildEnvPrices(): Record<string, BillingPriceSet> {
|
||||
const env = Config.stripe.prices;
|
||||
const prices: Record<string, BillingPriceSet> = {};
|
||||
if (!env) return prices;
|
||||
for (const entry of ENV_CATALOG) {
|
||||
const set: BillingPriceSet = {
|
||||
monthly: normalizeId(env[entry.monthly]),
|
||||
yearly: normalizeId(env[entry.yearly]),
|
||||
gift_1_month: normalizeId(env[entry.gift_1_month]),
|
||||
gift_1_year: normalizeId(env[entry.gift_1_year]),
|
||||
};
|
||||
if (set.monthly || set.yearly || set.gift_1_month || set.gift_1_year) {
|
||||
prices[entry.currency] = set;
|
||||
}
|
||||
}
|
||||
return prices;
|
||||
}
|
||||
|
||||
function buildEnvLegacyPrices(): Record<string, Array<string>> {
|
||||
const legacy: Record<string, Array<string>> = {};
|
||||
const env: unknown = Config.stripe.legacyPrices;
|
||||
if (!env || typeof env !== 'object' || Array.isArray(env)) return legacy;
|
||||
for (const [slot, ids] of Object.entries(env as Record<string, unknown>)) {
|
||||
if (Array.isArray(ids)) legacy[slot] = ids.filter((id): id is string => typeof id === 'string');
|
||||
}
|
||||
return legacy;
|
||||
}
|
||||
|
||||
function buildOperatorPrices(stored: NonNullable<StoredBillingConfig['prices']>): Record<string, BillingPriceSet> {
|
||||
const prices: Record<string, BillingPriceSet> = {};
|
||||
for (const [currency, set] of Object.entries(stored)) {
|
||||
prices[currency] = {
|
||||
monthly: normalizeId(set.monthly),
|
||||
yearly: normalizeId(set.yearly),
|
||||
gift_1_month: normalizeId(set.gift_1_month),
|
||||
gift_1_year: normalizeId(set.gift_1_year),
|
||||
};
|
||||
}
|
||||
return prices;
|
||||
}
|
||||
|
||||
function computeEffectiveBillingConfig(): Omit<EffectiveBillingConfig, 'version'> {
|
||||
const selfHosted = Config.instance.selfHosted;
|
||||
const stored = selfHosted ? storedBillingConfig : null;
|
||||
const operatorPrices = stored?.prices ?? null;
|
||||
const catalogMode: BillingCatalogMode = operatorPrices === null ? 'env' : 'operator';
|
||||
return {
|
||||
enabled: stored?.enabled ?? Config.stripe.enabled,
|
||||
secretKey: normalizeId(stored?.stripe_secret_key) ?? normalizeId(Config.stripe.secretKey),
|
||||
webhookSecret: normalizeId(stored?.stripe_webhook_secret) ?? normalizeId(Config.stripe.webhookSecret),
|
||||
automaticTax: selfHosted ? (stored?.automatic_tax ?? false) : true,
|
||||
taxIdCollection: selfHosted ? (stored?.tax_id_collection ?? false) : true,
|
||||
termsConsentRequired: selfHosted ? (stored?.terms_consent_required ?? false) : true,
|
||||
catalogMode,
|
||||
defaultCurrency: catalogMode === 'operator' ? (stored?.default_currency ?? null) : null,
|
||||
prices: operatorPrices === null ? buildEnvPrices() : buildOperatorPrices(operatorPrices),
|
||||
countryCurrencies: catalogMode === 'operator' ? {...(stored?.country_currencies ?? {})} : {},
|
||||
legacyPrices:
|
||||
stored?.legacy_prices != null
|
||||
? Object.fromEntries(Object.entries(stored.legacy_prices).map(([slot, ids]) => [slot, [...ids]]))
|
||||
: buildEnvLegacyPrices(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getEffectiveBillingConfig(): EffectiveBillingConfig {
|
||||
const effective = computeEffectiveBillingConfig();
|
||||
const source = JSON.stringify(effective);
|
||||
if (memoizedConfig !== null && memoizedSource === source) {
|
||||
return memoizedConfig;
|
||||
}
|
||||
const config: EffectiveBillingConfig = {
|
||||
...effective,
|
||||
version: createHash('sha256').update(source).digest('hex').slice(0, 32),
|
||||
};
|
||||
memoizedSource = source;
|
||||
memoizedConfig = config;
|
||||
return config;
|
||||
}
|
||||
|
||||
export function hasRecurringPricePair(config: EffectiveBillingConfig = getEffectiveBillingConfig()): boolean {
|
||||
return Object.values(config.prices).some((set) => set.monthly !== null && set.yearly !== null);
|
||||
}
|
||||
|
||||
export function getOperatorCurrencyPreferences(
|
||||
countryCode: string | null | undefined,
|
||||
config: EffectiveBillingConfig = getEffectiveBillingConfig(),
|
||||
): Array<string> {
|
||||
const configured = Object.keys(config.prices);
|
||||
const country = countryCode ? countryCode.toUpperCase() : null;
|
||||
const candidates = [
|
||||
country ? config.countryCurrencies[country] : undefined,
|
||||
config.defaultCurrency ?? undefined,
|
||||
...configured,
|
||||
];
|
||||
const preferences: Array<string> = [];
|
||||
for (const currency of candidates) {
|
||||
if (currency && configured.includes(currency) && !preferences.includes(currency)) {
|
||||
preferences.push(currency);
|
||||
}
|
||||
}
|
||||
return preferences;
|
||||
}
|
||||
|
||||
export function isPremiumTieringActive(): boolean {
|
||||
return !Config.instance.selfHosted || getCachedInstancePremiumMode() === 'mirror';
|
||||
}
|
||||
|
||||
export function isBillingActive(config: EffectiveBillingConfig = getEffectiveBillingConfig()): boolean {
|
||||
return config.enabled && config.secretKey !== null && hasRecurringPricePair(config) && isPremiumTieringActive();
|
||||
}
|
||||
|
||||
export function isStripeServiceable(config: EffectiveBillingConfig = getEffectiveBillingConfig()): boolean {
|
||||
if (config.secretKey === null) {
|
||||
return false;
|
||||
}
|
||||
return Config.instance.selfHosted ? isPremiumTieringActive() : config.enabled;
|
||||
}
|
||||
|
||||
export function isCurrentCatalogPriceId(
|
||||
priceId: string,
|
||||
config: EffectiveBillingConfig = getEffectiveBillingConfig(),
|
||||
): boolean {
|
||||
return Object.values(config.prices).some(
|
||||
(set) =>
|
||||
set.monthly === priceId || set.yearly === priceId || set.gift_1_month === priceId || set.gift_1_year === priceId,
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {
|
||||
type BillingPriceSet,
|
||||
type EffectiveBillingConfig,
|
||||
getActiveStoredBillingConfig,
|
||||
getEffectiveBillingConfig,
|
||||
} from '@app/api/stripe/BillingConfigCache';
|
||||
import type {Currency} from '@app/api/utils/CurrencyUtils';
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
|
||||
@@ -64,13 +70,25 @@ const LEGACY_SLOT_CURRENCIES: Record<string, Currency | undefined> = {
|
||||
try: 'TRY',
|
||||
};
|
||||
|
||||
function parseLegacySlot(slot: string): ProductInfo | null {
|
||||
const OPERATOR_CURRENCY_PATTERN = /^[A-Z]{3}$/;
|
||||
|
||||
const CATALOG_SLOTS: ReadonlyArray<keyof BillingPriceSet> = ['monthly', 'yearly', 'gift_1_month', 'gift_1_year'];
|
||||
|
||||
function parseLegacySlotCurrency(suffix: string, catalogMode: EffectiveBillingConfig['catalogMode']): Currency | null {
|
||||
if (catalogMode === 'operator') {
|
||||
const upper = suffix.toUpperCase();
|
||||
return OPERATOR_CURRENCY_PATTERN.test(upper) ? upper : null;
|
||||
}
|
||||
return LEGACY_SLOT_CURRENCIES[suffix.toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
function parseLegacySlot(slot: string, catalogMode: EffectiveBillingConfig['catalogMode']): ProductInfo | null {
|
||||
const separatorIndex = slot.lastIndexOf('_');
|
||||
if (separatorIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
const shape = LEGACY_SLOT_SHAPES[slot.slice(0, separatorIndex)];
|
||||
const currency = LEGACY_SLOT_CURRENCIES[slot.slice(separatorIndex + 1).toLowerCase()];
|
||||
const currency = parseLegacySlotCurrency(slot.slice(separatorIndex + 1), catalogMode);
|
||||
if (!shape || !currency) {
|
||||
return null;
|
||||
}
|
||||
@@ -79,306 +97,55 @@ function parseLegacySlot(slot: string): ProductInfo | null {
|
||||
|
||||
export class ProductRegistry {
|
||||
private products = new Map<string, ProductInfo>();
|
||||
private readonly config: EffectiveBillingConfig;
|
||||
|
||||
constructor() {
|
||||
constructor(config: EffectiveBillingConfig = getEffectiveBillingConfig()) {
|
||||
this.config = config;
|
||||
this.registerConfiguredProducts();
|
||||
this.registerLegacyProducts();
|
||||
}
|
||||
|
||||
get version(): string {
|
||||
return this.config.version;
|
||||
}
|
||||
|
||||
private registerConfiguredProducts(): void {
|
||||
const prices = Config.stripe.prices;
|
||||
if (!prices) return;
|
||||
this.registerProduct(prices.monthlyUsd, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'USD',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyEur, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'EUR',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyBrl, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'BRL',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyDkk, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'DKK',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyInr, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'INR',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyNok, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'NOK',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyPln, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'PLN',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlySek, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'SEK',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyTry, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'TRY',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyUsd, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency: 'USD',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyEur, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency: 'EUR',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyBrl, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency: 'BRL',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyDkk, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency: 'DKK',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyInr, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency: 'INR',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyNok, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency: 'NOK',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyPln, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency: 'PLN',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlySek, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency: 'SEK',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyTry, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency: 'TRY',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthUsd, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency: 'USD',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthEur, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency: 'EUR',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthBrl, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency: 'BRL',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthInr, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency: 'INR',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthDkk, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency: 'DKK',
|
||||
});
|
||||
this.registerProduct(prices.gift1YearDkk, {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'DKK',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthNok, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency: 'NOK',
|
||||
});
|
||||
this.registerProduct(prices.gift1YearNok, {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'NOK',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthSek, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency: 'SEK',
|
||||
});
|
||||
this.registerProduct(prices.gift1YearSek, {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'SEK',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthPln, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency: 'PLN',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthTry, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency: 'TRY',
|
||||
});
|
||||
this.registerProduct(prices.gift1YearUsd, {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'USD',
|
||||
});
|
||||
this.registerProduct(prices.gift1YearEur, {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'EUR',
|
||||
});
|
||||
this.registerProduct(prices.gift1YearBrl, {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'BRL',
|
||||
});
|
||||
this.registerProduct(prices.gift1YearInr, {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'INR',
|
||||
});
|
||||
this.registerProduct(prices.gift1YearPln, {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'PLN',
|
||||
});
|
||||
this.registerProduct(prices.gift1YearTry, {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'TRY',
|
||||
});
|
||||
for (const [currency, set] of Object.entries(this.config.prices)) {
|
||||
for (const slot of CATALOG_SLOTS) {
|
||||
const shape = LEGACY_SLOT_SHAPES[slot];
|
||||
if (shape) {
|
||||
this.registerProduct(set[slot] ?? undefined, {...shape, currency});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resolveLegacyPrices(): unknown {
|
||||
if (this.config.catalogMode === 'env' && getActiveStoredBillingConfig()?.legacy_prices == null) {
|
||||
return Config.stripe.legacyPrices;
|
||||
}
|
||||
return this.config.legacyPrices;
|
||||
}
|
||||
|
||||
private registerLegacyProducts(): void {
|
||||
const legacyPrices = Config.stripe.legacyPrices;
|
||||
const legacyPrices = this.resolveLegacyPrices();
|
||||
if (!legacyPrices) return;
|
||||
if (typeof legacyPrices !== 'object' || Array.isArray(legacyPrices)) {
|
||||
Logger.warn({}, 'Ignoring legacy Stripe price configuration that is not an object of slot names to price ids');
|
||||
return;
|
||||
}
|
||||
for (const [slot, priceIds] of Object.entries(legacyPrices)) {
|
||||
for (const [slot, priceIds] of Object.entries(legacyPrices as Record<string, unknown>)) {
|
||||
if (!Array.isArray(priceIds)) {
|
||||
Logger.warn({slot}, 'Ignoring legacy Stripe price slot that is not a list of price IDs');
|
||||
continue;
|
||||
}
|
||||
const info = parseLegacySlot(slot);
|
||||
const info = parseLegacySlot(slot, this.config.catalogMode);
|
||||
if (!info) {
|
||||
Logger.warn({slot}, 'Ignoring legacy Stripe price slot with an unrecognised name or currency');
|
||||
continue;
|
||||
}
|
||||
for (const priceId of priceIds) {
|
||||
if (!priceId || this.products.has(priceId)) {
|
||||
if (typeof priceId !== 'string' || !priceId || this.products.has(priceId)) {
|
||||
continue;
|
||||
}
|
||||
this.products.set(priceId, info);
|
||||
@@ -392,6 +159,10 @@ export class ProductRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
private getPriceSet(currency: string): BillingPriceSet | null {
|
||||
return this.config.prices[currency.trim().toUpperCase()] ?? null;
|
||||
}
|
||||
|
||||
getProduct(priceId: string): ProductInfo | null {
|
||||
return this.products.get(priceId) || null;
|
||||
}
|
||||
@@ -401,74 +172,27 @@ export class ProductRegistry {
|
||||
}
|
||||
|
||||
getRecurringSubscriptionPriceId(billingCycle: RecurringBillingCycle, currency: string): string | null {
|
||||
const normalizedCurrency = currency.trim().toLowerCase();
|
||||
const prices = Config.stripe.prices;
|
||||
if (!prices) {
|
||||
const set = this.getPriceSet(currency);
|
||||
if (!set) {
|
||||
return null;
|
||||
}
|
||||
if (normalizedCurrency === 'eur') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyEur ?? null) : (prices.yearlyEur ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'brl') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyBrl ?? null) : (prices.yearlyBrl ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'dkk') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyDkk ?? null) : (prices.yearlyDkk ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'inr') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyInr ?? null) : (prices.yearlyInr ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'nok') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyNok ?? null) : (prices.yearlyNok ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'pln') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyPln ?? null) : (prices.yearlyPln ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'sek') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlySek ?? null) : (prices.yearlySek ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'try') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyTry ?? null) : (prices.yearlyTry ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'usd') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyUsd ?? null) : (prices.yearlyUsd ?? null);
|
||||
}
|
||||
return null;
|
||||
return billingCycle === 'monthly' ? set.monthly : set.yearly;
|
||||
}
|
||||
|
||||
getGiftPriceId(duration: 'gift_1_month' | 'gift_1_year', currency: string): string | null {
|
||||
const normalizedCurrency = currency.trim().toLowerCase();
|
||||
const prices = Config.stripe.prices;
|
||||
if (!prices) {
|
||||
const set = this.getPriceSet(currency);
|
||||
if (!set) {
|
||||
return null;
|
||||
}
|
||||
if (normalizedCurrency === 'eur') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthEur ?? null) : (prices.gift1YearEur ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'brl') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthBrl ?? null) : (prices.gift1YearBrl ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'inr') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthInr ?? null) : (prices.gift1YearInr ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'dkk') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthDkk ?? null) : (prices.gift1YearDkk ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'nok') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthNok ?? null) : (prices.gift1YearNok ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'sek') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthSek ?? null) : (prices.gift1YearSek ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'pln') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthPln ?? null) : (prices.gift1YearPln ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'try') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthTry ?? null) : (prices.gift1YearTry ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'usd') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthUsd ?? null) : (prices.gift1YearUsd ?? null);
|
||||
}
|
||||
return null;
|
||||
return duration === 'gift_1_month' ? set.gift_1_month : set.gift_1_year;
|
||||
}
|
||||
}
|
||||
|
||||
let cachedRegistry: ProductRegistry | null = null;
|
||||
|
||||
export function getProductRegistry(config: EffectiveBillingConfig = getEffectiveBillingConfig()): ProductRegistry {
|
||||
if (cachedRegistry === null || cachedRegistry.version !== config.version) {
|
||||
cachedRegistry = new ProductRegistry(config);
|
||||
}
|
||||
return cachedRegistry;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {
|
||||
type EffectiveBillingConfig,
|
||||
getEffectiveBillingConfig,
|
||||
isStripeServiceable,
|
||||
} from '@app/api/stripe/BillingConfigCache';
|
||||
import {STRIPE_API_VERSION} from '@app/api/stripe/StripeApiVersion';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
let cachedClient: {secretKey: string; client: Stripe} | null = null;
|
||||
|
||||
function createStripeClient(secretKey: string): Stripe {
|
||||
return new Stripe(secretKey, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
httpClient: Config.dev.testModeEnabled
|
||||
? Stripe.createFetchHttpClient((input, init) => globalThis.fetch(input, init))
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function getStripeClient(config: EffectiveBillingConfig = getEffectiveBillingConfig()): Stripe | null {
|
||||
if (!config.secretKey || !isStripeServiceable(config)) {
|
||||
return null;
|
||||
}
|
||||
if (cachedClient === null || cachedClient.secretKey !== config.secretKey) {
|
||||
cachedClient = {secretKey: config.secretKey, client: createStripeClient(config.secretKey)};
|
||||
}
|
||||
return cachedClient.client;
|
||||
}
|
||||
@@ -6,10 +6,17 @@ import {CaptchaMiddleware} from '@app/api/middleware/CaptchaMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import {
|
||||
getEffectiveBillingConfig,
|
||||
isBillingActive,
|
||||
isPremiumTieringActive,
|
||||
isStripeServiceable,
|
||||
} from '@app/api/stripe/BillingConfigCache';
|
||||
import {mapGiftCodeToMetadataResponse, mapGiftCodeToResponse} from '@app/api/stripe/StripeModel';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {lookupGeoip} from '@app/api/utils/IpUtils';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {AppNotFoundHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
|
||||
import {StripeWebhookNotAvailableError} from '@fluxer/errors/src/domains/payment/StripeWebhookNotAvailableError';
|
||||
import {StripeWebhookSignatureInvalidError} from '@fluxer/errors/src/domains/payment/StripeWebhookSignatureInvalidError';
|
||||
import {StripeWebhookSignatureMissingError} from '@fluxer/errors/src/domains/payment/StripeWebhookSignatureMissingError';
|
||||
@@ -32,15 +39,37 @@ import {
|
||||
UrlResponse,
|
||||
WebhookReceivedResponse,
|
||||
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
|
||||
async function getPurchaseGeoipCountryCode(request: Request): Promise<string | null> {
|
||||
const geoip = await lookupGeoip(request);
|
||||
return geoip.countryCode ?? null;
|
||||
}
|
||||
|
||||
function routeAvailableWhen(isAvailable: () => boolean) {
|
||||
return createMiddleware<HonoEnv>(async (ctx, next) => {
|
||||
if (Config.instance.selfHosted && !isAvailable()) {
|
||||
return AppNotFoundHandler(ctx);
|
||||
}
|
||||
return next();
|
||||
});
|
||||
}
|
||||
|
||||
function isStripeWebhookAvailable(): boolean {
|
||||
const billing = getEffectiveBillingConfig();
|
||||
return billing.webhookSecret !== null && isStripeServiceable(billing);
|
||||
}
|
||||
|
||||
const HostedOnlyRoute = routeAvailableWhen(() => false);
|
||||
const GiftRouteAvailable = routeAvailableWhen(isPremiumTieringActive);
|
||||
const BillingRouteAvailable = routeAvailableWhen(() => isBillingActive());
|
||||
const StripeServicingRouteAvailable = routeAvailableWhen(() => isStripeServiceable());
|
||||
const StripeWebhookRouteAvailable = routeAvailableWhen(isStripeWebhookAvailable);
|
||||
|
||||
export function StripeController(app: HonoApp) {
|
||||
app.post(
|
||||
'/stripe/webhook',
|
||||
StripeWebhookRouteAvailable,
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_WEBHOOK),
|
||||
OpenAPI({
|
||||
operationId: 'process_stripe_webhook',
|
||||
@@ -57,12 +86,13 @@ export function StripeController(app: HonoApp) {
|
||||
throw new StripeWebhookSignatureMissingError();
|
||||
}
|
||||
const stripe = ctx.get('stripeService').getStripe();
|
||||
if (!stripe || !Config.stripe.webhookSecret) {
|
||||
const webhookSecret = getEffectiveBillingConfig().webhookSecret;
|
||||
if (!stripe || !webhookSecret) {
|
||||
throw new StripeWebhookNotAvailableError();
|
||||
}
|
||||
const body = await ctx.req.text();
|
||||
try {
|
||||
stripe.webhooks.constructEvent(body, signature, Config.stripe.webhookSecret);
|
||||
stripe.webhooks.constructEvent(body, signature, webhookSecret);
|
||||
} catch {
|
||||
throw new StripeWebhookSignatureInvalidError();
|
||||
}
|
||||
@@ -72,6 +102,7 @@ export function StripeController(app: HonoApp) {
|
||||
);
|
||||
app.post(
|
||||
'/stripe/checkout/subscription',
|
||||
BillingRouteAvailable,
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_CHECKOUT_SUBSCRIPTION),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
@@ -111,6 +142,7 @@ export function StripeController(app: HonoApp) {
|
||||
);
|
||||
app.post(
|
||||
'/stripe/checkout/subscription/preapproval',
|
||||
BillingRouteAvailable,
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_CHECKOUT_SUBSCRIPTION_PREAPPROVAL),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
@@ -143,6 +175,7 @@ export function StripeController(app: HonoApp) {
|
||||
);
|
||||
app.post(
|
||||
'/stripe/checkout/subscription/preapproval/continue',
|
||||
BillingRouteAvailable,
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_CHECKOUT_SUBSCRIPTION_PREAPPROVAL_CONTINUE),
|
||||
OpenAPI({
|
||||
operationId: 'continue_localized_card_preapproval_session',
|
||||
@@ -163,6 +196,7 @@ export function StripeController(app: HonoApp) {
|
||||
);
|
||||
app.post(
|
||||
'/stripe/checkout/gift',
|
||||
BillingRouteAvailable,
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_CHECKOUT_GIFT),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
@@ -195,6 +229,7 @@ export function StripeController(app: HonoApp) {
|
||||
);
|
||||
app.get(
|
||||
'/gifts/:code',
|
||||
GiftRouteAvailable,
|
||||
RateLimitMiddleware(RateLimitConfigs.GIFT_CODE_GET),
|
||||
OpenAPI({
|
||||
operationId: 'get_gift_code',
|
||||
@@ -220,6 +255,7 @@ export function StripeController(app: HonoApp) {
|
||||
);
|
||||
app.post(
|
||||
'/gifts/:code/redeem',
|
||||
GiftRouteAvailable,
|
||||
RateLimitMiddleware(RateLimitConfigs.GIFT_CODE_REDEEM),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
@@ -243,6 +279,7 @@ export function StripeController(app: HonoApp) {
|
||||
);
|
||||
app.get(
|
||||
'/users/@me/gifts',
|
||||
GiftRouteAvailable,
|
||||
RateLimitMiddleware(RateLimitConfigs.GIFTS_LIST),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
@@ -272,6 +309,7 @@ export function StripeController(app: HonoApp) {
|
||||
);
|
||||
app.get(
|
||||
'/premium/price-ids',
|
||||
BillingRouteAvailable,
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_PRICE_IDS),
|
||||
Validator('query', PriceIdsQueryRequest),
|
||||
OpenAPI({
|
||||
@@ -292,6 +330,7 @@ export function StripeController(app: HonoApp) {
|
||||
);
|
||||
app.get(
|
||||
'/premium/current-subscription-price',
|
||||
StripeServicingRouteAvailable,
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_CURRENT_SUBSCRIPTION_PRICE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
@@ -313,6 +352,7 @@ export function StripeController(app: HonoApp) {
|
||||
);
|
||||
app.post(
|
||||
'/premium/customer-portal',
|
||||
StripeServicingRouteAvailable,
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_CUSTOMER_PORTAL),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
@@ -334,6 +374,7 @@ export function StripeController(app: HonoApp) {
|
||||
);
|
||||
app.post(
|
||||
'/premium/grace/end',
|
||||
StripeServicingRouteAvailable,
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_PREMIUM_GRACE_END),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
@@ -355,6 +396,7 @@ export function StripeController(app: HonoApp) {
|
||||
);
|
||||
app.post(
|
||||
'/users/@me/age-verification',
|
||||
HostedOnlyRoute,
|
||||
RateLimitMiddleware(RateLimitConfigs.AGE_VERIFICATION),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
@@ -369,13 +411,18 @@ export function StripeController(app: HonoApp) {
|
||||
tags: 'Billing',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const ageVerificationService = ctx.get('ageVerificationService');
|
||||
if (!ageVerificationService) {
|
||||
return AppNotFoundHandler(ctx);
|
||||
}
|
||||
const userId = ctx.get('user').id;
|
||||
const url = await ctx.get('ageVerificationService').createVerificationSession(userId);
|
||||
const url = await ageVerificationService.createVerificationSession(userId);
|
||||
return ctx.json({url});
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/premium/refund-eligibility',
|
||||
HostedOnlyRoute,
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_REFUND_ELIGIBILITY),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
@@ -397,6 +444,7 @@ export function StripeController(app: HonoApp) {
|
||||
);
|
||||
app.post(
|
||||
'/premium/refund-latest',
|
||||
HostedOnlyRoute,
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_REFUND_LATEST),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
@@ -418,6 +466,7 @@ export function StripeController(app: HonoApp) {
|
||||
);
|
||||
app.post(
|
||||
'/premium/cancel-subscription',
|
||||
StripeServicingRouteAvailable,
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_SUBSCRIPTION_CANCEL),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
@@ -438,6 +487,7 @@ export function StripeController(app: HonoApp) {
|
||||
);
|
||||
app.post(
|
||||
'/premium/reactivate-subscription',
|
||||
StripeServicingRouteAvailable,
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_SUBSCRIPTION_REACTIVATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
@@ -458,6 +508,7 @@ export function StripeController(app: HonoApp) {
|
||||
);
|
||||
app.post(
|
||||
'/premium/change-subscription',
|
||||
StripeServicingRouteAvailable,
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_SUBSCRIPTION_CHANGE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
@@ -481,6 +532,7 @@ export function StripeController(app: HonoApp) {
|
||||
);
|
||||
app.post(
|
||||
'/premium/switch-to-list-price',
|
||||
HostedOnlyRoute,
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_SUBSCRIPTION_CHANGE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
@@ -502,6 +554,7 @@ export function StripeController(app: HonoApp) {
|
||||
);
|
||||
app.post(
|
||||
'/premium/cancel-pending-subscription-change',
|
||||
StripeServicingRouteAvailable,
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_SUBSCRIPTION_CHANGE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
@@ -523,6 +576,7 @@ export function StripeController(app: HonoApp) {
|
||||
);
|
||||
app.post(
|
||||
'/premium/visionary/rejoin',
|
||||
HostedOnlyRoute,
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_VISIONARY_REJOIN),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {BillingRepository} from '@app/api/billing/repositories/BillingRepository';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import {seconds} from 'itty-time';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
export interface StripePriceSummary {
|
||||
unitAmountMinor: number | null;
|
||||
}
|
||||
|
||||
const PRICE_CACHE_TTL_SECONDS = seconds('1 hour');
|
||||
const PRICE_CACHE_PRODUCE_TIMEOUT_MS = 90000;
|
||||
|
||||
export async function getCachedStripePriceSummary({
|
||||
stripe,
|
||||
cacheService,
|
||||
priceId,
|
||||
mirror,
|
||||
}: {
|
||||
stripe: Stripe | null;
|
||||
cacheService: ICacheService;
|
||||
priceId: string | null;
|
||||
mirror?: BillingRepository;
|
||||
}): Promise<StripePriceSummary | null> {
|
||||
if (!priceId || !stripe) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await cacheService.getOrSet<StripePriceSummary>(
|
||||
`stripe_price_summary:${priceId}`,
|
||||
async () => {
|
||||
const price = await stripe.prices.retrieve(priceId);
|
||||
if (mirror) {
|
||||
try {
|
||||
await mirror.prices.upsertFromStripe(price);
|
||||
} catch (mirrorErr) {
|
||||
Logger.error({mirrorErr, priceId}, 'Mirror upsert failed after Stripe price lookup');
|
||||
}
|
||||
}
|
||||
return {
|
||||
unitAmountMinor: price.unit_amount ?? null,
|
||||
};
|
||||
},
|
||||
PRICE_CACHE_TTL_SECONDS,
|
||||
PRICE_CACHE_PRODUCE_TIMEOUT_MS,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
Logger.warn({error, priceId}, 'Failed to retrieve Stripe price summary');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import type {GuildService} from '@app/api/guild/services/GuildService';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {GiftCode} from '@app/api/models/GiftCode';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {ProductRegistry} from '@app/api/stripe/ProductRegistry';
|
||||
import {STRIPE_API_VERSION} from '@app/api/stripe/StripeApiVersion';
|
||||
import {getProductRegistry, type ProductRegistry} from '@app/api/stripe/ProductRegistry';
|
||||
import {getStripeClient} from '@app/api/stripe/StripeClient';
|
||||
import {PremiumStateService} from '@app/api/stripe/services/PremiumStateService';
|
||||
import type {
|
||||
ContinueLocalizedCardPreapprovalResult,
|
||||
@@ -32,10 +32,10 @@ import type {
|
||||
SwitchToListPriceResponse,
|
||||
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import Stripe from 'stripe';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
export class StripeService {
|
||||
private stripe: Stripe | null = null;
|
||||
private stripe: Stripe | null;
|
||||
private productRegistry: ProductRegistry;
|
||||
private checkoutService: StripeCheckoutService;
|
||||
private subscriptionService: StripeSubscriptionService;
|
||||
@@ -52,13 +52,8 @@ export class StripeService {
|
||||
private cacheService: ICacheService,
|
||||
private billingRepository: BillingRepository,
|
||||
) {
|
||||
this.productRegistry = new ProductRegistry();
|
||||
if (Config.stripe.enabled && Config.stripe.secretKey) {
|
||||
this.stripe = new Stripe(Config.stripe.secretKey, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
httpClient: Config.dev.testModeEnabled ? Stripe.createFetchHttpClient() : undefined,
|
||||
});
|
||||
}
|
||||
this.productRegistry = getProductRegistry();
|
||||
this.stripe = getStripeClient();
|
||||
this.premiumService = new StripePremiumService(
|
||||
this.userRepository,
|
||||
this.gatewayService,
|
||||
@@ -70,6 +65,7 @@ export class StripeService {
|
||||
this.gatewayService,
|
||||
this.billingRepository,
|
||||
this.stripe,
|
||||
this.cacheService,
|
||||
);
|
||||
this.checkoutService = new StripeCheckoutService(
|
||||
this.stripe,
|
||||
@@ -164,7 +160,7 @@ export class StripeService {
|
||||
gift_1_month: string | null;
|
||||
gift_1_year: string | null;
|
||||
currency: Currency;
|
||||
gift_currency: Currency;
|
||||
gift_currency: Currency | null;
|
||||
monthly_amount_minor: number | null;
|
||||
yearly_amount_minor: number | null;
|
||||
gift_1_month_amount_minor: number | null;
|
||||
|
||||
@@ -12,8 +12,10 @@ import type {
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {RecurringBillingCycle} from '@app/api/stripe/ProductRegistry';
|
||||
import {ProductRegistry} from '@app/api/stripe/ProductRegistry';
|
||||
import {isBillingActive} from '@app/api/stripe/BillingConfigCache';
|
||||
import {getProductRegistry, type RecurringBillingCycle} from '@app/api/stripe/ProductRegistry';
|
||||
import {getStripeClient} from '@app/api/stripe/StripeClient';
|
||||
import {getCachedStripePriceSummary} from '@app/api/stripe/StripePriceSummaryCache';
|
||||
import {getPrimarySubscriptionItem} from '@app/api/stripe/StripeSubscriptionPeriod';
|
||||
import {
|
||||
SELF_SERVE_REFUND_COOLDOWN_DAYS,
|
||||
@@ -22,7 +24,12 @@ import {
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {checkHasActivePaidPremium} from '@app/api/user/UserHelpers';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
import {type Currency, getCurrencyPreferences, getGiftCurrencyPreferences} from '@app/api/utils/CurrencyUtils';
|
||||
import {
|
||||
type Currency,
|
||||
getCurrencyPreferences,
|
||||
getGiftCurrencyPreferences,
|
||||
normalizeCatalogCurrency,
|
||||
} from '@app/api/utils/CurrencyUtils';
|
||||
import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
@@ -39,6 +46,7 @@ import type {
|
||||
SelfServeRefundEligibilityResponse,
|
||||
SelfServeRefundIneligibilityReason,
|
||||
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
const INVOICE_LIMIT = 12;
|
||||
@@ -53,7 +61,7 @@ interface ResolvedPriceIds {
|
||||
gift_1_month: string | null;
|
||||
gift_1_year: string | null;
|
||||
currency: Currency;
|
||||
gift_currency: Currency;
|
||||
gift_currency: Currency | null;
|
||||
}
|
||||
|
||||
interface InvoiceResult {
|
||||
@@ -102,21 +110,7 @@ function billingCycleFromInterval(value: string | null | undefined): RecurringBi
|
||||
}
|
||||
|
||||
function normalizeCurrency(value: string | null | undefined): Currency | null {
|
||||
const currency = value?.toUpperCase();
|
||||
if (
|
||||
currency === 'USD' ||
|
||||
currency === 'EUR' ||
|
||||
currency === 'BRL' ||
|
||||
currency === 'DKK' ||
|
||||
currency === 'INR' ||
|
||||
currency === 'NOK' ||
|
||||
currency === 'PLN' ||
|
||||
currency === 'SEK' ||
|
||||
currency === 'TRY'
|
||||
) {
|
||||
return currency;
|
||||
}
|
||||
return null;
|
||||
return normalizeCatalogCurrency(value);
|
||||
}
|
||||
|
||||
function compareNullableDatesDesc(left: Date | null | undefined, right: Date | null | undefined): number {
|
||||
@@ -212,13 +206,14 @@ function mapPaymentMethod(row: BillingPaymentMethodRow): PremiumBillingPaymentMe
|
||||
}
|
||||
|
||||
export class PremiumStateService {
|
||||
private readonly productRegistry = new ProductRegistry();
|
||||
private readonly productRegistry = getProductRegistry();
|
||||
|
||||
constructor(
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly billingRepository: BillingRepository,
|
||||
private readonly stripe: Stripe | null = null,
|
||||
private readonly cacheService: ICacheService | null = null,
|
||||
) {}
|
||||
|
||||
async getState(userId: UserID, countryCode?: string): Promise<PremiumStateResponse> {
|
||||
@@ -294,7 +289,7 @@ export class PremiumStateService {
|
||||
premium_lifetime_sequence: user.premiumLifetimeSequence,
|
||||
premium_grace_ends_at: toIso(user.premiumGraceEndsAt),
|
||||
premium_enabled_override: (user.premiumFlags & PremiumFlags.ENABLED_OVERRIDE) !== 0,
|
||||
premium_purchase_disabled: (user.premiumFlags & PremiumFlags.PURCHASE_DISABLED) !== 0,
|
||||
premium_purchase_disabled: (user.premiumFlags & PremiumFlags.PURCHASE_DISABLED) !== 0 || !isBillingActive(),
|
||||
premium_perks_disabled: (user.premiumFlags & PremiumFlags.PERKS_DISABLED) !== 0,
|
||||
self_hosted: Config.instance.selfHosted,
|
||||
bot: user.isBot,
|
||||
@@ -640,7 +635,7 @@ export class PremiumStateService {
|
||||
billing_cycle: subscriptionPrice?.billing_cycle ?? null,
|
||||
effective_at: toIso(subscription?.current_period_end),
|
||||
};
|
||||
if (Config.instance.selfHosted || !Config.stripe.enabled || !Config.stripe.secretKey) {
|
||||
if (Config.instance.selfHosted || !isBillingActive()) {
|
||||
return {...base, available: false, reason: 'feature_unavailable'};
|
||||
}
|
||||
if (!subscription) {
|
||||
@@ -752,34 +747,55 @@ export class PremiumStateService {
|
||||
private async resolvePriceIds(countryCode: string | null): Promise<PriceIdsResponse | null> {
|
||||
const resolved = this.resolveConfiguredPriceIds(countryCode);
|
||||
if (!resolved) return null;
|
||||
const [monthlyPrice, yearlyPrice, gift1MonthPrice, gift1YearPrice] = await Promise.all([
|
||||
resolved.monthly ? this.billingRepository.prices.findById(resolved.monthly) : null,
|
||||
resolved.yearly ? this.billingRepository.prices.findById(resolved.yearly) : null,
|
||||
resolved.gift_1_month ? this.billingRepository.prices.findById(resolved.gift_1_month) : null,
|
||||
resolved.gift_1_year ? this.billingRepository.prices.findById(resolved.gift_1_year) : null,
|
||||
const [monthlyAmount, yearlyAmount, gift1MonthAmount, gift1YearAmount] = await Promise.all([
|
||||
this.resolvePriceAmountMinor(resolved.monthly),
|
||||
this.resolvePriceAmountMinor(resolved.yearly),
|
||||
this.resolvePriceAmountMinor(resolved.gift_1_month),
|
||||
this.resolvePriceAmountMinor(resolved.gift_1_year),
|
||||
]);
|
||||
return {
|
||||
...resolved,
|
||||
monthly_amount_minor: nullableNumber(monthlyPrice?.unit_amount),
|
||||
yearly_amount_minor: nullableNumber(yearlyPrice?.unit_amount),
|
||||
gift_1_month_amount_minor: nullableNumber(gift1MonthPrice?.unit_amount),
|
||||
gift_1_year_amount_minor: nullableNumber(gift1YearPrice?.unit_amount),
|
||||
monthly_amount_minor: monthlyAmount,
|
||||
yearly_amount_minor: yearlyAmount,
|
||||
gift_1_month_amount_minor: gift1MonthAmount,
|
||||
gift_1_year_amount_minor: gift1YearAmount,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolvePriceAmountMinor(priceId: string | null): Promise<number | null> {
|
||||
if (!priceId) {
|
||||
return null;
|
||||
}
|
||||
const mirrored = await this.billingRepository.prices.findById(priceId);
|
||||
if (mirrored) {
|
||||
return nullableNumber(mirrored.unit_amount);
|
||||
}
|
||||
if (!Config.instance.selfHosted || !this.cacheService) {
|
||||
return null;
|
||||
}
|
||||
const summary = await getCachedStripePriceSummary({
|
||||
stripe: this.stripe,
|
||||
cacheService: this.cacheService,
|
||||
priceId,
|
||||
mirror: this.billingRepository,
|
||||
});
|
||||
return summary?.unitAmountMinor ?? null;
|
||||
}
|
||||
|
||||
private resolveConfiguredPriceIds(countryCode: string | null): ResolvedPriceIds | null {
|
||||
const recurringCurrencyPreferences = getCurrencyPreferences(countryCode);
|
||||
const giftCurrencyPreferences = getGiftCurrencyPreferences(countryCode);
|
||||
const recurringPrices = this.resolveRecurringPriceIds(recurringCurrencyPreferences);
|
||||
const giftPrices = this.resolveGiftPriceIds(giftCurrencyPreferences);
|
||||
if (!recurringPrices || !giftPrices) return null;
|
||||
if (!recurringPrices) return null;
|
||||
if (!giftPrices && !Config.instance.selfHosted) return null;
|
||||
return {
|
||||
monthly: recurringPrices.monthly,
|
||||
yearly: recurringPrices.yearly,
|
||||
gift_1_month: giftPrices.gift_1_month,
|
||||
gift_1_year: giftPrices.gift_1_year,
|
||||
gift_1_month: giftPrices?.gift_1_month ?? null,
|
||||
gift_1_year: giftPrices?.gift_1_year ?? null,
|
||||
currency: recurringPrices.currency,
|
||||
gift_currency: giftPrices.gift_currency,
|
||||
gift_currency: giftPrices?.gift_currency ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -826,7 +842,7 @@ export class PremiumStateService {
|
||||
user: User,
|
||||
invoices: Array<BillingInvoiceRow>,
|
||||
): Promise<SelfServeRefundEligibilityResponse> {
|
||||
if (Config.instance.selfHosted || !Config.stripe.enabled || !Config.stripe.secretKey) {
|
||||
if (Config.instance.selfHosted || !getStripeClient()) {
|
||||
return refundEligibility({reason: 'feature_unavailable'});
|
||||
}
|
||||
const cooldownExpiresAt = this.cooldownExpiresAt(user);
|
||||
|
||||
@@ -8,7 +8,10 @@ import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {getBillingBranding} from '@app/api/stripe/BillingBranding';
|
||||
import {getEffectiveBillingConfig, isCurrentCatalogPriceId} from '@app/api/stripe/BillingConfigCache';
|
||||
import type {ProductInfo, ProductRegistry} from '@app/api/stripe/ProductRegistry';
|
||||
import {getCachedStripePriceSummary, type StripePriceSummary} from '@app/api/stripe/StripePriceSummaryCache';
|
||||
import {
|
||||
canProvisionPremiumFromSubscriptionStatus,
|
||||
getPremiumWillCancelFromSubscription,
|
||||
@@ -20,7 +23,12 @@ import {
|
||||
} from '@app/api/stripe/StripeSubscriptionPeriod';
|
||||
import {extractId} from '@app/api/stripe/StripeUtils';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {type Currency, getCurrencyPreferences, getGiftCurrencyPreferences} from '@app/api/utils/CurrencyUtils';
|
||||
import {
|
||||
type Currency,
|
||||
getCurrencyPreferences,
|
||||
getGiftCurrencyPreferences,
|
||||
isLocalizedCurrency,
|
||||
} from '@app/api/utils/CurrencyUtils';
|
||||
import {isEuEeaCountryCode} from '@fluxer/constants/src/EuropeanEconomicArea';
|
||||
import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {PurchaseEmailVerificationRequiredError} from '@fluxer/errors/src/domains/auth/EmailVerificationRequiredError';
|
||||
@@ -37,10 +45,12 @@ import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import {seconds} from 'itty-time';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
const PRODUCT_NAME = 'Fluxer';
|
||||
const PREMIUM_TIER_NAME = 'Plutonium';
|
||||
export const EU_WITHDRAWAL_WAIVER_TEXT_VERSION = '2026-04-23';
|
||||
|
||||
function isStripeResourceMissingError(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'resource_missing';
|
||||
}
|
||||
|
||||
type CheckoutSessionCreateParams = Stripe.Checkout.SessionCreateParams;
|
||||
type CheckoutSessionMode = CheckoutSessionCreateParams['mode'];
|
||||
type CheckoutSessionPaymentMethodType = NonNullable<CheckoutSessionCreateParams['payment_method_types']>[number];
|
||||
@@ -82,7 +92,6 @@ export interface CreateCheckoutSessionParams {
|
||||
isBusiness?: boolean;
|
||||
}
|
||||
|
||||
const UPI_MANDATE_DESCRIPTION = 'Fluxer Premium';
|
||||
const PIX_UPI_MANDATE_HEADROOM_MULTIPLIER = 1.25;
|
||||
|
||||
interface ResolvedPriceIds {
|
||||
@@ -91,7 +100,7 @@ interface ResolvedPriceIds {
|
||||
gift_1_month: string | null;
|
||||
gift_1_year: string | null;
|
||||
currency: Currency;
|
||||
gift_currency: Currency;
|
||||
gift_currency: Currency | null;
|
||||
}
|
||||
|
||||
interface PriceIdsResponse extends ResolvedPriceIds {
|
||||
@@ -101,10 +110,6 @@ interface PriceIdsResponse extends ResolvedPriceIds {
|
||||
gift_1_year_amount_minor: number | null;
|
||||
}
|
||||
|
||||
interface StripePriceSummary {
|
||||
unitAmountMinor: number | null;
|
||||
}
|
||||
|
||||
interface EuWithdrawalWaiverContext {
|
||||
accepted: boolean;
|
||||
acceptedAt: Date | null;
|
||||
@@ -199,6 +204,8 @@ export class StripeCheckoutService {
|
||||
priceId,
|
||||
});
|
||||
const paymentMethodTypes = this.resolvePaymentMethodTypes(paymentMethod);
|
||||
const branding = await getBillingBranding();
|
||||
const billing = getEffectiveBillingConfig();
|
||||
const checkoutMetadata = {
|
||||
user_id: userId.toString(),
|
||||
price_id: priceId,
|
||||
@@ -217,18 +224,22 @@ export class StripeCheckoutService {
|
||||
customer: customerId,
|
||||
client_reference_id: userId.toString(),
|
||||
metadata: checkoutMetadata,
|
||||
consent_collection: {
|
||||
terms_of_service: 'required',
|
||||
},
|
||||
custom_text: {
|
||||
terms_of_service_acceptance: {
|
||||
message: getContentMessage('billing.eu_withdrawal_waiver_checkout', user.locale, {
|
||||
product_name: PRODUCT_NAME,
|
||||
premium_tier_name: PREMIUM_TIER_NAME,
|
||||
terms_url: `${Config.endpoints.marketing}/terms`,
|
||||
}),
|
||||
},
|
||||
},
|
||||
...(billing.termsConsentRequired
|
||||
? {
|
||||
consent_collection: {
|
||||
terms_of_service: 'required',
|
||||
},
|
||||
custom_text: {
|
||||
terms_of_service_acceptance: {
|
||||
message: getContentMessage('billing.eu_withdrawal_waiver_checkout', user.locale, {
|
||||
product_name: branding.productName,
|
||||
premium_tier_name: branding.premiumName,
|
||||
terms_url: branding.termsUrl,
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
@@ -246,10 +257,10 @@ export class StripeCheckoutService {
|
||||
}
|
||||
: {}),
|
||||
automatic_tax: {
|
||||
enabled: true,
|
||||
enabled: billing.automaticTax,
|
||||
},
|
||||
tax_id_collection: {
|
||||
enabled: true,
|
||||
enabled: billing.taxIdCollection,
|
||||
},
|
||||
customer_update: {
|
||||
address: 'auto',
|
||||
@@ -353,7 +364,7 @@ export class StripeCheckoutService {
|
||||
success_url: `${Config.endpoints.webApp}/premium-callback?status=preapproval-success&token=${encodeURIComponent(token)}`,
|
||||
cancel_url: `${Config.endpoints.webApp}/premium-callback?status=preapproval-cancel`,
|
||||
tax_id_collection: {
|
||||
enabled: true,
|
||||
enabled: getEffectiveBillingConfig().taxIdCollection,
|
||||
},
|
||||
billing_address_collection: isBusiness ? 'required' : 'auto',
|
||||
customer_update: {
|
||||
@@ -559,12 +570,17 @@ export class StripeCheckoutService {
|
||||
);
|
||||
throw new StripeInvalidProductConfigurationError();
|
||||
}
|
||||
const billing = getEffectiveBillingConfig();
|
||||
if (billing.catalogMode === 'operator' && !isCurrentCatalogPriceId(priceId, billing)) {
|
||||
Logger.error({priceId, userId}, 'Checkout requested for a price outside the current operator catalog');
|
||||
throw new StripeInvalidProductError();
|
||||
}
|
||||
const enforcedCountryCode = this.resolveEnforcedPricingCountryCode({countryCode, purchaseGeoipCountryCode});
|
||||
if (this.requiresCountryCodeForLocalizedCurrency(productInfo.currency) && !enforcedCountryCode) {
|
||||
Logger.error({priceId, userId, currency: productInfo.currency}, 'Localized price requested without country code');
|
||||
throw new StripeInvalidProductConfigurationError();
|
||||
}
|
||||
if (enforcedCountryCode) {
|
||||
if (enforcedCountryCode && billing.catalogMode === 'env') {
|
||||
this.assertPriceMatchesCountryCatalog({countryCode: enforcedCountryCode, priceId, isGift, userId});
|
||||
}
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
@@ -723,15 +739,11 @@ export class StripeCheckoutService {
|
||||
}
|
||||
|
||||
private requiresLocalizedCardPreapproval(productInfo: ProductInfo): boolean {
|
||||
return (
|
||||
this.productRegistry.isRecurringSubscription(productInfo) &&
|
||||
productInfo.currency !== 'USD' &&
|
||||
productInfo.currency !== 'EUR'
|
||||
);
|
||||
return this.productRegistry.isRecurringSubscription(productInfo) && isLocalizedCurrency(productInfo.currency);
|
||||
}
|
||||
|
||||
private requiresCountryCodeForLocalizedCurrency(currency: Currency): boolean {
|
||||
return currency !== 'USD' && currency !== 'EUR';
|
||||
return isLocalizedCurrency(currency);
|
||||
}
|
||||
|
||||
private async rejectLocalizedCardPreapproval(
|
||||
@@ -988,18 +1000,23 @@ export class StripeCheckoutService {
|
||||
if (!user.stripeCustomerId) {
|
||||
throw new StripeNoPurchaseHistoryError();
|
||||
}
|
||||
const portalUser = await this.ensureStripeCustomer(user);
|
||||
const customerId = portalUser.stripeCustomerId;
|
||||
if (!customerId) {
|
||||
throw new StripeNoPurchaseHistoryError();
|
||||
}
|
||||
try {
|
||||
const session = await this.stripe.billingPortal.sessions.create({
|
||||
customer: user.stripeCustomerId,
|
||||
customer: customerId,
|
||||
return_url: `${Config.endpoints.webApp}/premium-callback?status=closed-billing-portal`,
|
||||
});
|
||||
if (!session.url) {
|
||||
Logger.error({userId, customerId: user.stripeCustomerId}, 'Stripe customer portal session missing url');
|
||||
Logger.error({userId, customerId}, 'Stripe customer portal session missing url');
|
||||
throw new StripeError('Stripe customer portal session missing url');
|
||||
}
|
||||
return session.url;
|
||||
} catch (error: unknown) {
|
||||
Logger.error({error, userId, customerId: user.stripeCustomerId}, 'Failed to create customer portal session');
|
||||
Logger.error({error, userId, customerId}, 'Failed to create customer portal session');
|
||||
const message = error instanceof Error ? error.message : 'Failed to create customer portal session';
|
||||
throw new StripeError(message);
|
||||
}
|
||||
@@ -1037,8 +1054,6 @@ export class StripeCheckoutService {
|
||||
private static readonly CUSTOMER_LOCK_TTL_SECONDS = seconds('30 seconds');
|
||||
private static readonly LOCALIZED_CARD_PREAPPROVAL_CONTINUE_LOCK_TTL_SECONDS = seconds('30 seconds');
|
||||
private static readonly LOCALIZED_CARD_PREAPPROVAL_TTL_SECONDS = seconds('1 day');
|
||||
private static readonly PRICE_CACHE_TTL_SECONDS = seconds('1 hour');
|
||||
private static readonly PRICE_CACHE_PRODUCE_TIMEOUT_MS = 90000;
|
||||
|
||||
private resolveConfiguredPriceIds(countryCode?: string): ResolvedPriceIds {
|
||||
const recurringCurrencyPreferences = getCurrencyPreferences(countryCode);
|
||||
@@ -1078,217 +1093,36 @@ export class StripeCheckoutService {
|
||||
return resolvedPrices;
|
||||
}
|
||||
}
|
||||
if (Config.instance.selfHosted) {
|
||||
return {gift_1_month: null, gift_1_year: null, gift_currency: null};
|
||||
}
|
||||
throw new StripeError(`Stripe gift price ids missing for supported currencies: ${preferredCurrencies.join(', ')}`);
|
||||
}
|
||||
|
||||
private getConfiguredRecurringPriceIdsForCurrency(
|
||||
currency: Currency,
|
||||
): Pick<ResolvedPriceIds, 'monthly' | 'yearly' | 'currency'> | null {
|
||||
const prices = Config.stripe.prices;
|
||||
if (!prices) {
|
||||
const monthly = this.productRegistry.getRecurringSubscriptionPriceId('monthly', currency);
|
||||
const yearly = this.productRegistry.getRecurringSubscriptionPriceId('yearly', currency);
|
||||
if (!monthly || !yearly) {
|
||||
return null;
|
||||
}
|
||||
switch (currency) {
|
||||
case 'EUR':
|
||||
if (!prices.monthlyEur || !prices.yearlyEur) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
monthly: prices.monthlyEur,
|
||||
yearly: prices.yearlyEur,
|
||||
currency,
|
||||
};
|
||||
case 'BRL':
|
||||
if (!prices.monthlyBrl || !prices.yearlyBrl) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
monthly: prices.monthlyBrl,
|
||||
yearly: prices.yearlyBrl,
|
||||
currency,
|
||||
};
|
||||
case 'DKK':
|
||||
if (!prices.monthlyDkk || !prices.yearlyDkk) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
monthly: prices.monthlyDkk,
|
||||
yearly: prices.yearlyDkk,
|
||||
currency,
|
||||
};
|
||||
case 'INR':
|
||||
if (!prices.monthlyInr || !prices.yearlyInr) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
monthly: prices.monthlyInr,
|
||||
yearly: prices.yearlyInr,
|
||||
currency,
|
||||
};
|
||||
case 'NOK':
|
||||
if (!prices.monthlyNok || !prices.yearlyNok) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
monthly: prices.monthlyNok,
|
||||
yearly: prices.yearlyNok,
|
||||
currency,
|
||||
};
|
||||
case 'PLN':
|
||||
if (!prices.monthlyPln || !prices.yearlyPln) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
monthly: prices.monthlyPln,
|
||||
yearly: prices.yearlyPln,
|
||||
currency,
|
||||
};
|
||||
case 'SEK':
|
||||
if (!prices.monthlySek || !prices.yearlySek) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
monthly: prices.monthlySek,
|
||||
yearly: prices.yearlySek,
|
||||
currency,
|
||||
};
|
||||
case 'TRY':
|
||||
if (!prices.monthlyTry || !prices.yearlyTry) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
monthly: prices.monthlyTry,
|
||||
yearly: prices.yearlyTry,
|
||||
currency,
|
||||
};
|
||||
case 'USD':
|
||||
if (!prices.monthlyUsd || !prices.yearlyUsd) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
monthly: prices.monthlyUsd,
|
||||
yearly: prices.yearlyUsd,
|
||||
currency,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return {monthly, yearly, currency};
|
||||
}
|
||||
|
||||
private getConfiguredGiftPriceIdsForCurrency(
|
||||
currency: Currency,
|
||||
): Pick<ResolvedPriceIds, 'gift_1_month' | 'gift_1_year' | 'gift_currency'> | null {
|
||||
const prices = Config.stripe.prices;
|
||||
if (!prices) {
|
||||
const gift1Month = this.productRegistry.getGiftPriceId('gift_1_month', currency);
|
||||
const gift1Year = this.productRegistry.getGiftPriceId('gift_1_year', currency);
|
||||
if (!gift1Month || !gift1Year) {
|
||||
return null;
|
||||
}
|
||||
switch (currency) {
|
||||
case 'BRL':
|
||||
if (!prices.gift1MonthBrl || !prices.gift1YearBrl) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
gift_1_month: prices.gift1MonthBrl,
|
||||
gift_1_year: prices.gift1YearBrl,
|
||||
gift_currency: 'BRL',
|
||||
};
|
||||
case 'INR':
|
||||
if (!prices.gift1MonthInr || !prices.gift1YearInr) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
gift_1_month: prices.gift1MonthInr,
|
||||
gift_1_year: prices.gift1YearInr,
|
||||
gift_currency: 'INR',
|
||||
};
|
||||
case 'DKK':
|
||||
if (!prices.gift1MonthDkk || !prices.gift1YearDkk) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
gift_1_month: prices.gift1MonthDkk,
|
||||
gift_1_year: prices.gift1YearDkk,
|
||||
gift_currency: 'DKK',
|
||||
};
|
||||
case 'NOK':
|
||||
if (!prices.gift1MonthNok || !prices.gift1YearNok) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
gift_1_month: prices.gift1MonthNok,
|
||||
gift_1_year: prices.gift1YearNok,
|
||||
gift_currency: 'NOK',
|
||||
};
|
||||
case 'SEK':
|
||||
if (!prices.gift1MonthSek || !prices.gift1YearSek) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
gift_1_month: prices.gift1MonthSek,
|
||||
gift_1_year: prices.gift1YearSek,
|
||||
gift_currency: 'SEK',
|
||||
};
|
||||
case 'PLN':
|
||||
if (!prices.gift1MonthPln || !prices.gift1YearPln) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
gift_1_month: prices.gift1MonthPln,
|
||||
gift_1_year: prices.gift1YearPln,
|
||||
gift_currency: 'PLN',
|
||||
};
|
||||
case 'TRY':
|
||||
if (!prices.gift1MonthTry || !prices.gift1YearTry) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
gift_1_month: prices.gift1MonthTry,
|
||||
gift_1_year: prices.gift1YearTry,
|
||||
gift_currency: 'TRY',
|
||||
};
|
||||
case 'EUR':
|
||||
if (!prices.gift1MonthEur || !prices.gift1YearEur) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
gift_1_month: prices.gift1MonthEur,
|
||||
gift_1_year: prices.gift1YearEur,
|
||||
gift_currency: 'EUR',
|
||||
};
|
||||
case 'USD':
|
||||
if (!prices.gift1MonthUsd || !prices.gift1YearUsd) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
gift_1_month: prices.gift1MonthUsd,
|
||||
gift_1_year: prices.gift1YearUsd,
|
||||
gift_currency: 'USD',
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return {gift_1_month: gift1Month, gift_1_year: gift1Year, gift_currency: currency};
|
||||
}
|
||||
|
||||
private async getStripePriceSummary(priceId: string | null): Promise<StripePriceSummary | null> {
|
||||
if (!priceId || !this.stripe) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await this.cacheService.getOrSet<StripePriceSummary>(
|
||||
`stripe_price_summary:${priceId}`,
|
||||
async () => {
|
||||
const price = await this.stripe!.prices.retrieve(priceId);
|
||||
return {
|
||||
unitAmountMinor: price.unit_amount ?? null,
|
||||
};
|
||||
},
|
||||
StripeCheckoutService.PRICE_CACHE_TTL_SECONDS,
|
||||
StripeCheckoutService.PRICE_CACHE_PRODUCE_TIMEOUT_MS,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
Logger.warn({error, priceId}, 'Failed to retrieve Stripe price summary');
|
||||
return null;
|
||||
}
|
||||
return getCachedStripePriceSummary({stripe: this.stripe, cacheService: this.cacheService, priceId});
|
||||
}
|
||||
|
||||
private assertPaymentMethodCompatibility({
|
||||
@@ -1311,6 +1145,10 @@ export class StripeCheckoutService {
|
||||
Logger.error({paymentMethod, priceId, userId}, 'Non-card payment method only valid for recurring subscriptions');
|
||||
throw new StripeInvalidProductConfigurationError();
|
||||
}
|
||||
if (getEffectiveBillingConfig().catalogMode === 'operator') {
|
||||
Logger.error({paymentMethod, priceId, userId}, 'Non-card payment methods are unavailable for operator prices');
|
||||
throw new StripeInvalidProductConfigurationError();
|
||||
}
|
||||
if (paymentMethod === 'pix' && productInfo.currency !== 'BRL') {
|
||||
Logger.error({priceId, userId, currency: productInfo.currency}, 'Pix payment method requires a BRL price');
|
||||
throw new StripeInvalidProductConfigurationError();
|
||||
@@ -1344,7 +1182,8 @@ export class StripeCheckoutService {
|
||||
paymentMethod: CheckoutPaymentMethod;
|
||||
priceId: string;
|
||||
}): Promise<CheckoutSessionPaymentMethodOptions | undefined> {
|
||||
if (productInfo.currency === 'BRL' && checkoutMode === 'payment') {
|
||||
const envCatalog = getEffectiveBillingConfig().catalogMode === 'env';
|
||||
if (envCatalog && productInfo.currency === 'BRL' && checkoutMode === 'payment') {
|
||||
return {
|
||||
pix: {
|
||||
amount_includes_iof: 'always',
|
||||
@@ -1368,13 +1207,13 @@ export class StripeCheckoutService {
|
||||
};
|
||||
}
|
||||
if (paymentMethod === 'upi') {
|
||||
const mandateAmount = await this.resolveMandateAmount(priceId);
|
||||
const [mandateAmount, branding] = await Promise.all([this.resolveMandateAmount(priceId), getBillingBranding()]);
|
||||
return {
|
||||
upi: {
|
||||
mandate_options: {
|
||||
amount: mandateAmount,
|
||||
amount_type: 'maximum',
|
||||
description: UPI_MANDATE_DESCRIPTION,
|
||||
description: branding.upiMandateDescription,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1390,7 +1229,48 @@ export class StripeCheckoutService {
|
||||
return Math.ceil(priceSummary.unitAmountMinor * PIX_UPI_MANDATE_HEADROOM_MULTIPLIER);
|
||||
}
|
||||
|
||||
private async ensureStripeCustomer(user: User): Promise<User> {
|
||||
private async clearStaleStripeCustomer(user: User): Promise<User> {
|
||||
if (!Config.instance.selfHosted || !this.stripe || !user.stripeCustomerId) {
|
||||
return user;
|
||||
}
|
||||
const customerId = user.stripeCustomerId;
|
||||
try {
|
||||
const customer = await this.stripe.customers.retrieve(customerId);
|
||||
if (!('deleted' in customer && customer.deleted)) {
|
||||
return user;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!isStripeResourceMissingError(error)) {
|
||||
Logger.warn({error, userId: user.id, customerId}, 'Failed to verify stored Stripe customer');
|
||||
return user;
|
||||
}
|
||||
}
|
||||
const patch: Partial<UserRow> = {stripe_customer_id: null};
|
||||
if (user.stripeSubscriptionId && (await this.isStripeSubscriptionMissing(user.stripeSubscriptionId))) {
|
||||
patch.stripe_subscription_id = null;
|
||||
}
|
||||
const updatedUser = await this.userRepository.patchUpsert(user.id, patch, user.toRow());
|
||||
Logger.info(
|
||||
{userId: user.id, customerId, clearedFields: Object.keys(patch)},
|
||||
'Cleared Stripe customer that no longer exists for the configured Stripe account',
|
||||
);
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
private async isStripeSubscriptionMissing(subscriptionId: string): Promise<boolean> {
|
||||
if (!this.stripe) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await this.stripe.subscriptions.retrieve(subscriptionId);
|
||||
return false;
|
||||
} catch (error: unknown) {
|
||||
return isStripeResourceMissingError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureStripeCustomer(existingUser: User): Promise<User> {
|
||||
const user = await this.clearStaleStripeCustomer(existingUser);
|
||||
if (user.stripeCustomerId) {
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {StripeGiftService} from '@app/api/stripe/services/StripeGiftService
|
||||
import type {StripePremiumService} from '@app/api/stripe/services/StripePremiumService';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
import {isLocalizedCurrency} from '@app/api/utils/CurrencyUtils';
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
@@ -719,7 +720,7 @@ export class StripeCheckoutWebhookHandler {
|
||||
}
|
||||
|
||||
private requiresLocalizedCardEligibility(productInfo: ProductInfo): boolean {
|
||||
return productInfo.currency !== 'USD' && productInfo.currency !== 'EUR';
|
||||
return isLocalizedCurrency(productInfo.currency);
|
||||
}
|
||||
|
||||
private getDeclaredCheckoutPaymentMethodType(session: Stripe.Checkout.Session): string | null {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {Logger} from '@app/api/Logger';
|
||||
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
|
||||
import {type GiftCode, mapGiftDurationMonthsToFields} from '@app/api/models/GiftCode';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {getBillingBranding} from '@app/api/stripe/BillingBranding';
|
||||
import type {ProductInfo} from '@app/api/stripe/ProductRegistry';
|
||||
import type {StripeCheckoutService} from '@app/api/stripe/services/StripeCheckoutService';
|
||||
import type {StripePremiumService} from '@app/api/stripe/services/StripePremiumService';
|
||||
@@ -119,7 +120,7 @@ export class StripeGiftService {
|
||||
Logger.debug({userId, giftCode: code}, 'Redeemer passed gift purchase validation');
|
||||
if (user.premiumType === UserPremiumTypes.LIFETIME) {
|
||||
Logger.debug({userId, giftCode: code}, 'Rejecting redemption for lifetime user');
|
||||
throw new CannotRedeemPlutoniumWithVisionaryError();
|
||||
throw new CannotRedeemPlutoniumWithVisionaryError((await getBillingBranding()).premiumName);
|
||||
}
|
||||
await this.userRepository.redeemGiftCode(code, userId);
|
||||
Logger.debug({userId, giftCode: code}, 'Applied gift redemption row update');
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {AdminRepository} from '@app/api/admin/AdminRepository';
|
||||
import {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import type {ISessionTerminator} from '@app/api/auth/ISessionTerminator';
|
||||
import type {BillingRepository} from '@app/api/billing/repositories/BillingRepository';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {IDonationRepository} from '@app/api/donation/IDonationRepository';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
|
||||
@@ -12,6 +11,7 @@ import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAcco
|
||||
import type {PremiumStateReconciliationQueueService} from '@app/api/infrastructure/PremiumStateReconciliationQueueService';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getAcceptedWebhookSecrets} from '@app/api/stripe/BillingConfigCache';
|
||||
import type {ProductRegistry} from '@app/api/stripe/ProductRegistry';
|
||||
import type {AgeVerificationService} from '@app/api/stripe/services/AgeVerificationService';
|
||||
import type {StripeCheckoutService} from '@app/api/stripe/services/StripeCheckoutService';
|
||||
@@ -116,18 +116,31 @@ export class StripeWebhookService {
|
||||
);
|
||||
}
|
||||
|
||||
private constructVerifiedEvent(
|
||||
stripe: Stripe,
|
||||
body: string,
|
||||
signature: string,
|
||||
webhookSecrets: Array<string>,
|
||||
): Stripe.Event {
|
||||
const SEVEN_DAYS_SECONDS = 7 * 24 * 60 * 60;
|
||||
let lastError: unknown = null;
|
||||
for (const webhookSecret of webhookSecrets) {
|
||||
try {
|
||||
return stripe.webhooks.constructEvent(body, signature, webhookSecret, SEVEN_DAYS_SECONDS);
|
||||
} catch (error: unknown) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
Logger.error({error: lastError}, 'Invalid webhook signature');
|
||||
throw new StripeWebhookSignatureInvalidError();
|
||||
}
|
||||
|
||||
async handleWebhook({body, signature}: HandleWebhookParams): Promise<void> {
|
||||
if (!this.stripe || !Config.stripe.webhookSecret) {
|
||||
const webhookSecrets = getAcceptedWebhookSecrets();
|
||||
if (!this.stripe || webhookSecrets.length === 0) {
|
||||
throw new StripeWebhookNotAvailableError();
|
||||
}
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
const SEVEN_DAYS_SECONDS = 7 * 24 * 60 * 60;
|
||||
event = this.stripe.webhooks.constructEvent(body, signature, Config.stripe.webhookSecret, SEVEN_DAYS_SECONDS);
|
||||
} catch (error: unknown) {
|
||||
Logger.error({error}, 'Invalid webhook signature');
|
||||
throw new StripeWebhookSignatureInvalidError();
|
||||
}
|
||||
const event = this.constructVerifiedEvent(this.stripe, body, signature, webhookSecrets);
|
||||
Logger.debug({eventType: event.type, eventId: event.id}, 'Processing Stripe webhook');
|
||||
const claim = await this.billingRepository.webhookEvents.tryClaim(event.id);
|
||||
if (claim === 'already_processed') {
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
|
||||
import {
|
||||
getAcceptedWebhookSecrets,
|
||||
getEffectiveBillingConfig,
|
||||
getOperatorCurrencyPreferences,
|
||||
getStoredBillingConfig,
|
||||
isBillingActive,
|
||||
isCurrentCatalogPriceId,
|
||||
isPremiumTieringActive,
|
||||
isStripeServiceable,
|
||||
type StoredBillingConfig,
|
||||
setStoredBillingConfig,
|
||||
} from '@app/api/stripe/BillingConfigCache';
|
||||
import {getStripeClient} from '@app/api/stripe/StripeClient';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const ENV_PRICES = {
|
||||
monthlyUsd: 'price_monthly_usd',
|
||||
yearlyUsd: 'price_yearly_usd',
|
||||
gift1MonthUsd: 'price_gift_month_usd',
|
||||
gift1YearUsd: 'price_gift_year_usd',
|
||||
monthlyEur: 'price_monthly_eur',
|
||||
yearlyEur: 'price_yearly_eur',
|
||||
gift1MonthEur: 'price_gift_month_eur',
|
||||
gift1YearEur: 'price_gift_year_eur',
|
||||
monthlyBrl: 'price_monthly_brl',
|
||||
yearlyBrl: 'price_yearly_brl',
|
||||
gift1MonthBrl: 'price_gift_month_brl',
|
||||
gift1YearBrl: 'price_gift_year_brl',
|
||||
monthlyDkk: 'price_monthly_dkk',
|
||||
yearlyDkk: 'price_yearly_dkk',
|
||||
gift1MonthDkk: 'price_gift_month_dkk',
|
||||
gift1YearDkk: 'price_gift_year_dkk',
|
||||
monthlyInr: 'price_monthly_inr',
|
||||
yearlyInr: 'price_yearly_inr',
|
||||
gift1MonthInr: 'price_gift_month_inr',
|
||||
gift1YearInr: 'price_gift_year_inr',
|
||||
monthlyNok: 'price_monthly_nok',
|
||||
yearlyNok: 'price_yearly_nok',
|
||||
gift1MonthNok: 'price_gift_month_nok',
|
||||
gift1YearNok: 'price_gift_year_nok',
|
||||
monthlyPln: 'price_monthly_pln',
|
||||
yearlyPln: 'price_yearly_pln',
|
||||
gift1MonthPln: 'price_gift_month_pln',
|
||||
gift1YearPln: 'price_gift_year_pln',
|
||||
monthlySek: 'price_monthly_sek',
|
||||
yearlySek: 'price_yearly_sek',
|
||||
gift1MonthSek: 'price_gift_month_sek',
|
||||
gift1YearSek: 'price_gift_year_sek',
|
||||
monthlyTry: 'price_monthly_try',
|
||||
yearlyTry: 'price_yearly_try',
|
||||
gift1MonthTry: 'price_gift_month_try',
|
||||
gift1YearTry: 'price_gift_year_try',
|
||||
};
|
||||
|
||||
function expectedEnvCatalog(): Record<string, Record<string, string>> {
|
||||
const catalog: Record<string, Record<string, string>> = {};
|
||||
for (const currency of ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY']) {
|
||||
const suffix = currency.toLowerCase();
|
||||
catalog[currency] = {
|
||||
monthly: `price_monthly_${suffix}`,
|
||||
yearly: `price_yearly_${suffix}`,
|
||||
gift_1_month: `price_gift_month_${suffix}`,
|
||||
gift_1_year: `price_gift_year_${suffix}`,
|
||||
};
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
function operatorConfig(overrides: Partial<StoredBillingConfig> = {}): StoredBillingConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
stripe_secret_key: 'sk_test_operator',
|
||||
stripe_webhook_secret: 'whsec_operator',
|
||||
automatic_tax: null,
|
||||
tax_id_collection: null,
|
||||
terms_consent_required: null,
|
||||
default_currency: 'GBP',
|
||||
prices: {
|
||||
GBP: {
|
||||
monthly: 'price_gbp_monthly',
|
||||
yearly: 'price_gbp_yearly',
|
||||
gift_1_month: 'price_gbp_gift_month',
|
||||
gift_1_year: 'price_gbp_gift_year',
|
||||
},
|
||||
CHF: {monthly: 'price_chf_monthly', yearly: 'price_chf_yearly', gift_1_month: null, gift_1_year: null},
|
||||
},
|
||||
country_currencies: {CH: 'CHF', LI: 'CHF'},
|
||||
legacy_prices: {monthly_GBP: ['price_gbp_old']},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('BillingConfigCache', () => {
|
||||
const stripe = Config.stripe;
|
||||
const instance = Config.instance;
|
||||
const original = {
|
||||
enabled: stripe.enabled,
|
||||
secretKey: stripe.secretKey,
|
||||
webhookSecret: stripe.webhookSecret,
|
||||
prices: stripe.prices,
|
||||
legacyPrices: stripe.legacyPrices,
|
||||
selfHosted: instance.selfHosted,
|
||||
};
|
||||
let originalPremiumMode = getCachedInstancePremiumMode();
|
||||
let originalStored = getStoredBillingConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
originalPremiumMode = getCachedInstancePremiumMode();
|
||||
originalStored = getStoredBillingConfig();
|
||||
instance.selfHosted = true;
|
||||
setCachedInstancePremiumMode('mirror');
|
||||
setStoredBillingConfig(null);
|
||||
stripe.prices = {...ENV_PRICES};
|
||||
stripe.legacyPrices = {monthly_try: ['price_old_try']};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stripe.enabled = original.enabled;
|
||||
stripe.secretKey = original.secretKey;
|
||||
stripe.webhookSecret = original.webhookSecret;
|
||||
stripe.prices = original.prices;
|
||||
stripe.legacyPrices = original.legacyPrices;
|
||||
instance.selfHosted = original.selfHosted;
|
||||
setCachedInstancePremiumMode(originalPremiumMode);
|
||||
setStoredBillingConfig(originalStored);
|
||||
});
|
||||
|
||||
it('builds the env catalog for the nine hosted currencies from Config.stripe', () => {
|
||||
const config = getEffectiveBillingConfig();
|
||||
expect(config.catalogMode).toBe('env');
|
||||
expect(config.prices).toEqual(expectedEnvCatalog());
|
||||
expect(config.countryCurrencies).toEqual({});
|
||||
expect(config.defaultCurrency).toBeNull();
|
||||
expect(config.legacyPrices).toEqual({monthly_try: ['price_old_try']});
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.secretKey).toBe('sk_test_fluxer');
|
||||
expect(config.webhookSecret).toBe('whsec_test_fluxer');
|
||||
});
|
||||
|
||||
it('leaves unconfigured env currencies out of the catalog', () => {
|
||||
stripe.prices = {monthlyUsd: 'price_a', yearlyUsd: 'price_b', gift1MonthSek: 'price_c'};
|
||||
expect(getEffectiveBillingConfig().prices).toEqual({
|
||||
USD: {monthly: 'price_a', yearly: 'price_b', gift_1_month: null, gift_1_year: null},
|
||||
SEK: {monthly: null, yearly: null, gift_1_month: 'price_c', gift_1_year: null},
|
||||
});
|
||||
});
|
||||
|
||||
it('reads env values at call time and changes the version when they change', () => {
|
||||
const before = getEffectiveBillingConfig();
|
||||
expect(getEffectiveBillingConfig()).toBe(before);
|
||||
stripe.prices = {...ENV_PRICES, monthlyUsd: 'price_changed'};
|
||||
const afterPrice = getEffectiveBillingConfig();
|
||||
expect(afterPrice.prices.USD?.monthly).toBe('price_changed');
|
||||
expect(afterPrice.version).not.toBe(before.version);
|
||||
stripe.webhookSecret = 'whsec_rotated';
|
||||
const afterSecret = getEffectiveBillingConfig();
|
||||
expect(afterSecret.webhookSecret).toBe('whsec_rotated');
|
||||
expect(afterSecret.version).not.toBe(afterPrice.version);
|
||||
});
|
||||
|
||||
it('uses the operator catalog when stored prices are set', () => {
|
||||
setStoredBillingConfig(operatorConfig());
|
||||
const config = getEffectiveBillingConfig();
|
||||
expect(config.catalogMode).toBe('operator');
|
||||
expect(config.prices).toEqual(operatorConfig().prices);
|
||||
expect(config.defaultCurrency).toBe('GBP');
|
||||
expect(config.countryCurrencies).toEqual({CH: 'CHF', LI: 'CHF'});
|
||||
expect(config.legacyPrices).toEqual({monthly_GBP: ['price_gbp_old']});
|
||||
expect(config.secretKey).toBe('sk_test_operator');
|
||||
expect(config.webhookSecret).toBe('whsec_operator');
|
||||
});
|
||||
|
||||
it('falls back to env for each stored null field', () => {
|
||||
setStoredBillingConfig({
|
||||
enabled: null,
|
||||
stripe_secret_key: null,
|
||||
stripe_webhook_secret: 'whsec_stored',
|
||||
automatic_tax: null,
|
||||
tax_id_collection: null,
|
||||
terms_consent_required: null,
|
||||
default_currency: null,
|
||||
prices: null,
|
||||
country_currencies: {SE: 'SEK'},
|
||||
legacy_prices: null,
|
||||
});
|
||||
const config = getEffectiveBillingConfig();
|
||||
expect(config.catalogMode).toBe('env');
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.secretKey).toBe('sk_test_fluxer');
|
||||
expect(config.webhookSecret).toBe('whsec_stored');
|
||||
expect(config.prices).toEqual(expectedEnvCatalog());
|
||||
expect(config.countryCurrencies).toEqual({});
|
||||
expect(config.legacyPrices).toEqual({monthly_try: ['price_old_try']});
|
||||
});
|
||||
|
||||
it('changes the version when the stored config changes', () => {
|
||||
const envVersion = getEffectiveBillingConfig().version;
|
||||
setStoredBillingConfig(operatorConfig());
|
||||
const operatorVersion = getEffectiveBillingConfig().version;
|
||||
expect(operatorVersion).not.toBe(envVersion);
|
||||
setStoredBillingConfig(operatorConfig({stripe_secret_key: 'sk_test_rotated'}));
|
||||
expect(getEffectiveBillingConfig().version).not.toBe(operatorVersion);
|
||||
setStoredBillingConfig(null);
|
||||
expect(getEffectiveBillingConfig().version).toBe(envVersion);
|
||||
});
|
||||
|
||||
it('orders operator currency preferences by country, then default, then catalog order', () => {
|
||||
setStoredBillingConfig(operatorConfig());
|
||||
expect(getOperatorCurrencyPreferences('ch')).toEqual(['CHF', 'GBP']);
|
||||
expect(getOperatorCurrencyPreferences('GB')).toEqual(['GBP', 'CHF']);
|
||||
expect(getOperatorCurrencyPreferences(null)).toEqual(['GBP', 'CHF']);
|
||||
setStoredBillingConfig(operatorConfig({default_currency: 'JPY', country_currencies: {CH: 'XXX'}}));
|
||||
expect(getOperatorCurrencyPreferences('CH')).toEqual(['GBP', 'CHF']);
|
||||
});
|
||||
|
||||
describe('isPremiumTieringActive and isBillingActive', () => {
|
||||
const cases: Array<{
|
||||
selfHosted: boolean;
|
||||
premiumMode: 'mirror' | 'everyone';
|
||||
stored: StoredBillingConfig | null;
|
||||
tiering: boolean;
|
||||
billing: boolean;
|
||||
}> = [
|
||||
{selfHosted: false, premiumMode: 'everyone', stored: null, tiering: true, billing: true},
|
||||
{selfHosted: false, premiumMode: 'mirror', stored: null, tiering: true, billing: true},
|
||||
{selfHosted: true, premiumMode: 'everyone', stored: null, tiering: false, billing: false},
|
||||
{selfHosted: true, premiumMode: 'mirror', stored: null, tiering: true, billing: true},
|
||||
{selfHosted: true, premiumMode: 'everyone', stored: operatorConfig(), tiering: false, billing: false},
|
||||
{selfHosted: true, premiumMode: 'mirror', stored: operatorConfig(), tiering: true, billing: true},
|
||||
{
|
||||
selfHosted: true,
|
||||
premiumMode: 'mirror',
|
||||
stored: operatorConfig({enabled: false}),
|
||||
tiering: true,
|
||||
billing: false,
|
||||
},
|
||||
{
|
||||
selfHosted: true,
|
||||
premiumMode: 'mirror',
|
||||
stored: operatorConfig({
|
||||
prices: {GBP: {monthly: 'price_gbp_monthly', yearly: null, gift_1_month: null, gift_1_year: null}},
|
||||
}),
|
||||
tiering: true,
|
||||
billing: false,
|
||||
},
|
||||
{
|
||||
selfHosted: true,
|
||||
premiumMode: 'mirror',
|
||||
stored: operatorConfig({prices: {}}),
|
||||
tiering: true,
|
||||
billing: false,
|
||||
},
|
||||
{
|
||||
selfHosted: false,
|
||||
premiumMode: 'everyone',
|
||||
stored: operatorConfig({prices: {}, enabled: false}),
|
||||
tiering: true,
|
||||
billing: true,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(cases)(
|
||||
'selfHosted=$selfHosted premiumMode=$premiumMode gives tiering=$tiering billing=$billing',
|
||||
({selfHosted, premiumMode, stored, tiering, billing}) => {
|
||||
instance.selfHosted = selfHosted;
|
||||
setCachedInstancePremiumMode(premiumMode);
|
||||
setStoredBillingConfig(stored);
|
||||
expect(isPremiumTieringActive()).toBe(tiering);
|
||||
expect(isBillingActive()).toBe(billing);
|
||||
},
|
||||
);
|
||||
|
||||
it('is inactive without a secret key, or when env billing is disabled', () => {
|
||||
instance.selfHosted = false;
|
||||
stripe.secretKey = '';
|
||||
expect(isBillingActive()).toBe(false);
|
||||
stripe.secretKey = original.secretKey;
|
||||
stripe.enabled = false;
|
||||
expect(isBillingActive()).toBe(false);
|
||||
setStoredBillingConfig(operatorConfig({enabled: true}));
|
||||
expect(isBillingActive()).toBe(false);
|
||||
instance.selfHosted = true;
|
||||
expect(isBillingActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('is inactive in env mode when no recurring pair is configured', () => {
|
||||
instance.selfHosted = false;
|
||||
stripe.prices = {monthlyUsd: 'price_a', gift1MonthUsd: 'price_b', gift1YearUsd: 'price_c'};
|
||||
expect(isBillingActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hosted instances', () => {
|
||||
it('ignore every stored billing field and keep the env config', () => {
|
||||
const hostedEnv = (() => {
|
||||
instance.selfHosted = false;
|
||||
return getEffectiveBillingConfig();
|
||||
})();
|
||||
setStoredBillingConfig(
|
||||
operatorConfig({
|
||||
enabled: false,
|
||||
automatic_tax: false,
|
||||
tax_id_collection: false,
|
||||
terms_consent_required: false,
|
||||
}),
|
||||
);
|
||||
const config = getEffectiveBillingConfig();
|
||||
expect(config).toBe(hostedEnv);
|
||||
expect(config.catalogMode).toBe('env');
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.secretKey).toBe('sk_test_fluxer');
|
||||
expect(config.webhookSecret).toBe('whsec_test_fluxer');
|
||||
expect(config.prices).toEqual(expectedEnvCatalog());
|
||||
expect(config.legacyPrices).toEqual({monthly_try: ['price_old_try']});
|
||||
expect(config.automaticTax).toBe(true);
|
||||
expect(config.taxIdCollection).toBe(true);
|
||||
expect(config.termsConsentRequired).toBe(true);
|
||||
expect(isBillingActive()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkout flags', () => {
|
||||
it('default to off on self-hosted and follow the stored values', () => {
|
||||
setStoredBillingConfig(operatorConfig());
|
||||
let config = getEffectiveBillingConfig();
|
||||
expect(config.automaticTax).toBe(false);
|
||||
expect(config.taxIdCollection).toBe(false);
|
||||
expect(config.termsConsentRequired).toBe(false);
|
||||
setStoredBillingConfig(
|
||||
operatorConfig({automatic_tax: true, tax_id_collection: false, terms_consent_required: true}),
|
||||
);
|
||||
config = getEffectiveBillingConfig();
|
||||
expect(config.automaticTax).toBe(true);
|
||||
expect(config.taxIdCollection).toBe(false);
|
||||
expect(config.termsConsentRequired).toBe(true);
|
||||
});
|
||||
|
||||
it('default to off on self-hosted without any stored config', () => {
|
||||
const config = getEffectiveBillingConfig();
|
||||
expect(config.automaticTax).toBe(false);
|
||||
expect(config.taxIdCollection).toBe(false);
|
||||
expect(config.termsConsentRequired).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStripeServiceable and getStripeClient', () => {
|
||||
it('follows env enabled plus key on hosted, exactly like before', () => {
|
||||
instance.selfHosted = false;
|
||||
expect(isStripeServiceable()).toBe(true);
|
||||
expect(getStripeClient()).not.toBeNull();
|
||||
stripe.enabled = false;
|
||||
expect(isStripeServiceable()).toBe(false);
|
||||
expect(getStripeClient()).toBeNull();
|
||||
stripe.enabled = true;
|
||||
stripe.secretKey = '';
|
||||
expect(isStripeServiceable()).toBe(false);
|
||||
expect(getStripeClient()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps servicing on self-hosted after sales are switched off or prices removed', () => {
|
||||
setStoredBillingConfig(operatorConfig({enabled: false, prices: {}}));
|
||||
expect(isBillingActive()).toBe(false);
|
||||
expect(isStripeServiceable()).toBe(true);
|
||||
expect(getStripeClient()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('stops servicing on self-hosted without a key or without premium tiering', () => {
|
||||
setStoredBillingConfig(operatorConfig({stripe_secret_key: null}));
|
||||
stripe.secretKey = '';
|
||||
expect(isStripeServiceable()).toBe(false);
|
||||
expect(getStripeClient()).toBeNull();
|
||||
stripe.secretKey = original.secretKey;
|
||||
setStoredBillingConfig(operatorConfig());
|
||||
setCachedInstancePremiumMode('everyone');
|
||||
expect(isStripeServiceable()).toBe(false);
|
||||
expect(getStripeClient()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCurrentCatalogPriceId', () => {
|
||||
it('matches current catalog ids only, never legacy ids', () => {
|
||||
setStoredBillingConfig(operatorConfig());
|
||||
expect(isCurrentCatalogPriceId('price_gbp_monthly')).toBe(true);
|
||||
expect(isCurrentCatalogPriceId('price_gbp_gift_year')).toBe(true);
|
||||
expect(isCurrentCatalogPriceId('price_chf_yearly')).toBe(true);
|
||||
expect(isCurrentCatalogPriceId('price_gbp_old')).toBe(false);
|
||||
expect(isCurrentCatalogPriceId('price_monthly_usd')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAcceptedWebhookSecrets', () => {
|
||||
beforeEach(() => {
|
||||
instance.selfHosted = false;
|
||||
getAcceptedWebhookSecrets();
|
||||
instance.selfHosted = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('remembers the previously effective env secret when a stored one replaces it', () => {
|
||||
getAcceptedWebhookSecrets();
|
||||
setStoredBillingConfig(operatorConfig({stripe_webhook_secret: 'whsec_first'}));
|
||||
expect(getAcceptedWebhookSecrets()).toEqual(['whsec_first', 'whsec_test_fluxer']);
|
||||
});
|
||||
|
||||
it('remembers the previous self-hosted webhook secret for 24 hours after a change', () => {
|
||||
const start = Date.now();
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(start);
|
||||
setStoredBillingConfig(operatorConfig({stripe_webhook_secret: 'whsec_first'}));
|
||||
expect(getAcceptedWebhookSecrets()).toEqual(['whsec_first']);
|
||||
setStoredBillingConfig(operatorConfig({stripe_webhook_secret: 'whsec_second'}));
|
||||
expect(getAcceptedWebhookSecrets()).toEqual(['whsec_second', 'whsec_first']);
|
||||
setStoredBillingConfig(operatorConfig({stripe_webhook_secret: 'whsec_second'}));
|
||||
expect(getAcceptedWebhookSecrets()).toEqual(['whsec_second', 'whsec_first']);
|
||||
now.mockReturnValue(start + 23 * 60 * 60 * 1000);
|
||||
expect(getAcceptedWebhookSecrets()).toEqual(['whsec_second', 'whsec_first']);
|
||||
now.mockReturnValue(start + 24 * 60 * 60 * 1000 + 1);
|
||||
expect(getAcceptedWebhookSecrets()).toEqual(['whsec_second']);
|
||||
});
|
||||
|
||||
it('drops a remembered secret once it becomes current again', () => {
|
||||
setStoredBillingConfig(operatorConfig({stripe_webhook_secret: 'whsec_first'}));
|
||||
setStoredBillingConfig(operatorConfig({stripe_webhook_secret: 'whsec_second'}));
|
||||
setStoredBillingConfig(operatorConfig({stripe_webhook_secret: 'whsec_first'}));
|
||||
expect(getAcceptedWebhookSecrets()).toEqual(['whsec_first', 'whsec_second']);
|
||||
});
|
||||
|
||||
it('accepts only the current env secret on hosted', () => {
|
||||
instance.selfHosted = false;
|
||||
expect(getAcceptedWebhookSecrets()).toEqual(['whsec_test_fluxer']);
|
||||
stripe.webhookSecret = 'whsec_rotated';
|
||||
expect(getAcceptedWebhookSecrets()).toEqual(['whsec_rotated']);
|
||||
setStoredBillingConfig(operatorConfig({stripe_webhook_secret: 'whsec_stored'}));
|
||||
expect(getAcceptedWebhookSecrets()).toEqual(['whsec_rotated']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,674 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
|
||||
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
|
||||
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {
|
||||
getEffectiveBillingConfig,
|
||||
getStoredBillingConfig,
|
||||
isBillingActive,
|
||||
type StoredBillingConfig,
|
||||
setStoredBillingConfig,
|
||||
} from '@app/api/stripe/BillingConfigCache';
|
||||
import {getProductRegistry, ProductRegistry, ProductType} from '@app/api/stripe/ProductRegistry';
|
||||
import {getStripeClient} from '@app/api/stripe/StripeClient';
|
||||
import {setupSyncStripeWebhookWorker} from '@app/api/stripe/tests/StripeWebhookTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import {
|
||||
createMockWebhookPayload,
|
||||
createStripeApiHandlers,
|
||||
type StripeApiHandlers,
|
||||
} from '@app/api/test/msw/handlers/StripeApiHandlers';
|
||||
import {server} from '@app/api/test/msw/server';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {getCurrencyPreferences, getGiftCurrencyPreferences, isLocalizedCurrency} from '@app/api/utils/CurrencyUtils';
|
||||
import processStripeWebhook from '@app/api/worker/tasks/ProcessStripeWebhook';
|
||||
import {setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import type {PremiumStateResponse, PriceIdsResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {HttpResponse, http} from 'msw';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
const ENV_CURRENCIES = ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY'] as const;
|
||||
|
||||
function buildFullEnvPrices(): NonNullable<typeof Config.stripe.prices> {
|
||||
const prices: Record<string, string> = {};
|
||||
for (const currency of ENV_CURRENCIES) {
|
||||
const suffix = currency.charAt(0) + currency.slice(1).toLowerCase();
|
||||
const lower = currency.toLowerCase();
|
||||
prices[`monthly${suffix}`] = `price_envmonthly${lower}`;
|
||||
prices[`yearly${suffix}`] = `price_envyearly${lower}`;
|
||||
prices[`gift1Month${suffix}`] = `price_envgiftmonth${lower}`;
|
||||
prices[`gift1Year${suffix}`] = `price_envgiftyear${lower}`;
|
||||
}
|
||||
return prices as NonNullable<typeof Config.stripe.prices>;
|
||||
}
|
||||
|
||||
const OPERATOR_PRICES = {
|
||||
GBP: {
|
||||
monthly: 'price_opgbpmonthly',
|
||||
yearly: 'price_opgbpyearly',
|
||||
gift_1_month: 'price_opgbpgiftmonth',
|
||||
gift_1_year: 'price_opgbpgiftyear',
|
||||
},
|
||||
SEK: {
|
||||
monthly: 'price_opsekmonthly',
|
||||
yearly: 'price_opsekyearly',
|
||||
gift_1_month: null,
|
||||
gift_1_year: null,
|
||||
},
|
||||
CHF: {
|
||||
monthly: 'price_opchfmonthly',
|
||||
yearly: 'price_opchfyearly',
|
||||
gift_1_month: 'price_opchfgiftmonth',
|
||||
gift_1_year: 'price_opchfgiftyear',
|
||||
},
|
||||
};
|
||||
|
||||
const STORED_WEBHOOK_SECRET = 'whsec_operator_stored';
|
||||
const ENV_WEBHOOK_SECRET = 'whsec_env_only';
|
||||
|
||||
function operatorBilling(overrides: Partial<StoredBillingConfig> = {}): StoredBillingConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
stripe_secret_key: 'sk_test_operator_a',
|
||||
stripe_webhook_secret: STORED_WEBHOOK_SECRET,
|
||||
automatic_tax: null,
|
||||
tax_id_collection: null,
|
||||
terms_consent_required: null,
|
||||
default_currency: 'GBP',
|
||||
prices: OPERATOR_PRICES,
|
||||
country_currencies: {SE: 'SEK', CH: 'CHF'},
|
||||
legacy_prices: {monthly_GBP: ['price_opgbplegacymonthly']},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface GlobalState {
|
||||
prices: typeof Config.stripe.prices;
|
||||
legacyPrices: typeof Config.stripe.legacyPrices;
|
||||
webhookSecret: typeof Config.stripe.webhookSecret;
|
||||
selfHosted: boolean;
|
||||
premiumMode: ReturnType<typeof getCachedInstancePremiumMode>;
|
||||
storedBilling: StoredBillingConfig | null;
|
||||
}
|
||||
|
||||
function captureGlobalState(): GlobalState {
|
||||
return {
|
||||
prices: Config.stripe.prices,
|
||||
legacyPrices: Config.stripe.legacyPrices,
|
||||
webhookSecret: Config.stripe.webhookSecret,
|
||||
selfHosted: Config.instance.selfHosted,
|
||||
premiumMode: getCachedInstancePremiumMode(),
|
||||
storedBilling: getStoredBillingConfig(),
|
||||
};
|
||||
}
|
||||
|
||||
function restoreGlobalState(state: GlobalState): void {
|
||||
Config.stripe.prices = state.prices;
|
||||
Config.stripe.legacyPrices = state.legacyPrices;
|
||||
Config.stripe.webhookSecret = state.webhookSecret;
|
||||
Config.instance.selfHosted = state.selfHosted;
|
||||
setCachedInstancePremiumMode(state.premiumMode);
|
||||
setStoredBillingConfig(state.storedBilling);
|
||||
}
|
||||
|
||||
const WORKER_HELPERS = {logger: new NoopLogger()} as unknown as WorkerTaskHelpers;
|
||||
|
||||
function useOperatorBilling(overrides: Partial<StoredBillingConfig> = {}): void {
|
||||
Config.instance.selfHosted = true;
|
||||
setCachedInstancePremiumMode('mirror');
|
||||
setStoredBillingConfig(operatorBilling(overrides));
|
||||
}
|
||||
|
||||
function sessionField(session: object | undefined, key: string): unknown {
|
||||
return session ? (session as Record<string, unknown>)[key] : undefined;
|
||||
}
|
||||
|
||||
function signWebhook(payload: string, timestamp: number, secret: string): string {
|
||||
const signature = crypto.createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex');
|
||||
return `t=${timestamp},v1=${signature}`;
|
||||
}
|
||||
|
||||
describe('operator billing catalog', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let stripeHandlers: StripeApiHandlers;
|
||||
let initialState: GlobalState;
|
||||
|
||||
async function createAccount(): Promise<Awaited<ReturnType<typeof createTestAccount>>> {
|
||||
const storedBilling = getStoredBillingConfig();
|
||||
const premiumMode = getCachedInstancePremiumMode();
|
||||
const account = await createTestAccount(harness);
|
||||
setStoredBillingConfig(storedBilling);
|
||||
setCachedInstancePremiumMode(premiumMode);
|
||||
return account;
|
||||
}
|
||||
|
||||
async function createPurchaser(): Promise<string> {
|
||||
const storedBilling = getStoredBillingConfig();
|
||||
const premiumMode = getCachedInstancePremiumMode();
|
||||
const account = await createAccount();
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/security-flags`)
|
||||
.body({email_verified: true})
|
||||
.execute();
|
||||
setStoredBillingConfig(storedBilling);
|
||||
setCachedInstancePremiumMode(premiumMode);
|
||||
return account.token;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
initialState = captureGlobalState();
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
restoreGlobalState(initialState);
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
restoreGlobalState(initialState);
|
||||
Config.stripe.prices = buildFullEnvPrices();
|
||||
Config.stripe.legacyPrices = {};
|
||||
stripeHandlers = createStripeApiHandlers({subscriptionsListEmpty: true});
|
||||
server.use(...stripeHandlers.handlers);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreGlobalState(initialState);
|
||||
});
|
||||
|
||||
describe('env catalog mode', () => {
|
||||
test('registers every env price with the same product shape as before', () => {
|
||||
const registry = new ProductRegistry();
|
||||
expect(getEffectiveBillingConfig().catalogMode).toBe('env');
|
||||
for (const currency of ENV_CURRENCIES) {
|
||||
const lower = currency.toLowerCase();
|
||||
expect(registry.getProduct(`price_envmonthly${lower}`)).toEqual({
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency,
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
expect(registry.getProduct(`price_envyearly${lower}`)).toEqual({
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency,
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
expect(registry.getProduct(`price_envgiftmonth${lower}`)).toEqual({
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency,
|
||||
});
|
||||
expect(registry.getProduct(`price_envgiftyear${lower}`)).toEqual({
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency,
|
||||
});
|
||||
expect(registry.getRecurringSubscriptionPriceId('monthly', lower)).toBe(`price_envmonthly${lower}`);
|
||||
expect(registry.getRecurringSubscriptionPriceId('yearly', currency)).toBe(`price_envyearly${lower}`);
|
||||
expect(registry.getGiftPriceId('gift_1_month', lower)).toBe(`price_envgiftmonth${lower}`);
|
||||
expect(registry.getGiftPriceId('gift_1_year', currency)).toBe(`price_envgiftyear${lower}`);
|
||||
}
|
||||
expect(registry.getRecurringSubscriptionPriceId('monthly', 'GBP')).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps the hosted country routing and localized currency rules', () => {
|
||||
expect(getCurrencyPreferences('SE')).toEqual(['SEK', 'EUR', 'USD']);
|
||||
expect(getCurrencyPreferences('GB')).toEqual(['USD', 'EUR']);
|
||||
expect(getGiftCurrencyPreferences('BR')).toEqual(['USD', 'EUR']);
|
||||
expect(isLocalizedCurrency('BRL')).toBe(true);
|
||||
expect(isLocalizedCurrency('USD')).toBe(false);
|
||||
expect(isLocalizedCurrency('EUR')).toBe(false);
|
||||
});
|
||||
|
||||
test('ignores legacy slots outside the hosted currencies', () => {
|
||||
Config.stripe.legacyPrices = {monthly_gbp: ['price_legacygbp'], monthly_brl: ['price_legacybrl']};
|
||||
const registry = getProductRegistry();
|
||||
expect(registry.getProduct('price_legacygbp')).toBeNull();
|
||||
expect(registry.getProduct('price_legacybrl')?.currency).toBe('BRL');
|
||||
});
|
||||
|
||||
test('serves the localized SEK price ids for a Swedish request', async () => {
|
||||
const prices = await createBuilder<PriceIdsResponse>(harness, '')
|
||||
.get('/premium/price-ids?country_code=SE')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(prices.currency).toBe('SEK');
|
||||
expect(prices.monthly).toBe('price_envmonthlysek');
|
||||
expect(prices.gift_currency).toBe('SEK');
|
||||
});
|
||||
|
||||
test('keeps the hosted tier name, required terms consent and tax collection on hosted', async () => {
|
||||
setStoredBillingConfig(operatorBilling({terms_consent_required: false, automatic_tax: false}));
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/gift')
|
||||
.body({price_id: 'price_envgiftmonthusd'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const session = stripeHandlers.spies.createdCheckoutSessions[0];
|
||||
expect(session?.line_items?.[0]?.price).toBe('price_envgiftmonthusd');
|
||||
expect(session?.custom_text?.terms_of_service_acceptance?.message).toContain('Fluxer Plutonium');
|
||||
expect(session?.consent_collection?.terms_of_service).toBe('required');
|
||||
expect(sessionField(session, 'automatic_tax')).toEqual({enabled: 'true'});
|
||||
expect(sessionField(session, 'tax_id_collection')).toEqual({enabled: 'true'});
|
||||
});
|
||||
|
||||
test('opens the portal with the stored customer on hosted without checking it first', async () => {
|
||||
server.use(...createStripeApiHandlers({customerShouldFail: true}).handlers);
|
||||
const account = await createAccount();
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/premium`)
|
||||
.body({stripe_customer_id: 'cus_hosted_existing'})
|
||||
.execute();
|
||||
await createBuilder<{url: string}>(harness, account.token)
|
||||
.post('/premium/customer-portal')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const user = await getUserRepository().findUnique(createUserID(BigInt(account.userId)));
|
||||
expect(user?.stripeCustomerId).toBe('cus_hosted_existing');
|
||||
});
|
||||
|
||||
test('still rejects a localized recurring price without a country code', async () => {
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: 'price_envmonthlybrl'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('operator catalog mode', () => {
|
||||
beforeEach(() => {
|
||||
useOperatorBilling();
|
||||
});
|
||||
|
||||
test('builds the catalog from the stored prices only', () => {
|
||||
const registry = getProductRegistry();
|
||||
expect(getEffectiveBillingConfig().catalogMode).toBe('operator');
|
||||
expect(registry.getProduct('price_opgbpmonthly')).toEqual({
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'GBP',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
expect(registry.getProduct('price_opgbplegacymonthly')?.currency).toBe('GBP');
|
||||
expect(registry.getProduct('price_envmonthlyusd')).toBeNull();
|
||||
expect(registry.getRecurringSubscriptionPriceId('yearly', 'gbp')).toBe('price_opgbpyearly');
|
||||
expect(registry.getGiftPriceId('gift_1_month', 'SEK')).toBeNull();
|
||||
expect(isLocalizedCurrency('GBP')).toBe(false);
|
||||
expect(isLocalizedCurrency('BRL')).toBe(false);
|
||||
});
|
||||
|
||||
test('routes countries through country_currencies, then the default currency', async () => {
|
||||
const swedish = await createBuilder<PriceIdsResponse>(harness, '')
|
||||
.get('/premium/price-ids?country_code=SE')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(swedish.currency).toBe('SEK');
|
||||
expect(swedish.monthly).toBe('price_opsekmonthly');
|
||||
expect(swedish.yearly).toBe('price_opsekyearly');
|
||||
expect(swedish.gift_currency).toBe('GBP');
|
||||
expect(swedish.gift_1_month).toBe('price_opgbpgiftmonth');
|
||||
|
||||
const swiss = await createBuilder<PriceIdsResponse>(harness, '')
|
||||
.get('/premium/price-ids?country_code=CH')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(swiss.currency).toBe('CHF');
|
||||
expect(swiss.gift_currency).toBe('CHF');
|
||||
|
||||
const american = await createBuilder<PriceIdsResponse>(harness, '')
|
||||
.get('/premium/price-ids?country_code=US')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(american.currency).toBe('GBP');
|
||||
expect(american.yearly).toBe('price_opgbpyearly');
|
||||
expect(american.gift_1_year).toBe('price_opgbpgiftyear');
|
||||
});
|
||||
|
||||
test('falls back to the first configured currency without a default', () => {
|
||||
setStoredBillingConfig(operatorBilling({default_currency: null, country_currencies: null}));
|
||||
expect(getCurrencyPreferences('US')).toEqual(['GBP', 'SEK', 'CHF']);
|
||||
expect(getGiftCurrencyPreferences('US')).toEqual(['GBP', 'CHF']);
|
||||
});
|
||||
|
||||
test('creates a GBP checkout with the stored price id and no country enforcement', async () => {
|
||||
const token = await createPurchaser();
|
||||
const withoutCountry = await createBuilder<{url: string}>(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: 'price_opgbpmonthly'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(withoutCountry.url).toContain('checkout.stripe.com');
|
||||
const session = stripeHandlers.spies.createdCheckoutSessions[0];
|
||||
expect(session?.mode).toBe('subscription');
|
||||
expect(session?.line_items?.[0]?.price).toBe('price_opgbpmonthly');
|
||||
expect(session?.metadata?.price_id).toBe('price_opgbpmonthly');
|
||||
expect(session?.payment_method_types).toBeUndefined();
|
||||
expect(session?.payment_method_options).toBeUndefined();
|
||||
expect(session?.consent_collection).toBeUndefined();
|
||||
expect(session?.custom_text).toBeUndefined();
|
||||
expect(sessionField(session, 'automatic_tax')).toEqual({enabled: 'false'});
|
||||
expect(sessionField(session, 'tax_id_collection')).toEqual({enabled: 'false'});
|
||||
});
|
||||
|
||||
test('sends terms consent and tax collection when the operator turns them on', async () => {
|
||||
useOperatorBilling({automatic_tax: true, tax_id_collection: true, terms_consent_required: true});
|
||||
const token = await createPurchaser();
|
||||
await createBuilder<{url: string}>(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: 'price_opgbpyearly'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const session = stripeHandlers.spies.createdCheckoutSessions[0];
|
||||
expect(session?.consent_collection?.terms_of_service).toBe('required');
|
||||
expect(session?.custom_text?.terms_of_service_acceptance?.message).toBeTruthy();
|
||||
expect(sessionField(session, 'automatic_tax')).toEqual({enabled: 'true'});
|
||||
expect(sessionField(session, 'tax_id_collection')).toEqual({enabled: 'true'});
|
||||
});
|
||||
|
||||
test('rejects legacy price ids for new checkouts', async () => {
|
||||
const token = await createPurchaser();
|
||||
expect(getProductRegistry().getProduct('price_opgbplegacymonthly')).not.toBeNull();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: 'price_opgbplegacymonthly'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('serves subscription prices without gifts when no currency has gift prices', async () => {
|
||||
useOperatorBilling({
|
||||
prices: {
|
||||
GBP: {monthly: 'price_opgbpmonthly', yearly: 'price_opgbpyearly', gift_1_month: null, gift_1_year: null},
|
||||
},
|
||||
});
|
||||
const prices = await createBuilder<PriceIdsResponse>(harness, '')
|
||||
.get('/premium/price-ids?country_code=GB')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(prices.currency).toBe('GBP');
|
||||
expect(prices.monthly).toBe('price_opgbpmonthly');
|
||||
expect(prices.yearly).toBe('price_opgbpyearly');
|
||||
expect(prices.gift_1_month ?? null).toBeNull();
|
||||
expect(prices.gift_1_year ?? null).toBeNull();
|
||||
expect(prices.gift_currency).toBeNull();
|
||||
const account = await createAccount();
|
||||
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
|
||||
.get('/premium/state')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(state.pricing.localized?.monthly).toBe('price_opgbpmonthly');
|
||||
expect(state.pricing.localized?.yearly).toBe('price_opgbpyearly');
|
||||
expect(state.pricing.localized?.gift_1_month ?? null).toBeNull();
|
||||
expect(state.pricing.localized?.gift_currency).toBeNull();
|
||||
});
|
||||
|
||||
test('fills premium state amounts from Stripe when the price mirror has no row', async () => {
|
||||
const suffix = crypto.randomBytes(4).toString('hex');
|
||||
const monthly = `price_mirrormiss${suffix}monthly`;
|
||||
const yearly = `price_mirrormiss${suffix}yearly`;
|
||||
useOperatorBilling({prices: {GBP: {monthly, yearly, gift_1_month: null, gift_1_year: null}}});
|
||||
expect(await getBillingRepository().prices.findById(monthly)).toBeNull();
|
||||
const account = await createAccount();
|
||||
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
|
||||
.get('/premium/state')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(state.pricing.localized?.monthly_amount_minor).toBe(499);
|
||||
expect(state.pricing.localized?.yearly_amount_minor).toBe(4999);
|
||||
expect(Number((await getBillingRepository().prices.findById(monthly))?.unit_amount)).toBe(499);
|
||||
});
|
||||
|
||||
test('replaces a stored customer that no longer exists in the configured Stripe account', async () => {
|
||||
server.use(
|
||||
...createStripeApiHandlers({
|
||||
subscriptionsListEmpty: true,
|
||||
customerShouldFail: true,
|
||||
subscriptionShouldFail: true,
|
||||
}).handlers,
|
||||
);
|
||||
const account = await createAccount();
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/security-flags`)
|
||||
.body({email_verified: true})
|
||||
.execute();
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/premium`)
|
||||
.body({stripe_customer_id: 'cus_from_old_account', stripe_subscription_id: 'sub_from_old_account'})
|
||||
.execute();
|
||||
useOperatorBilling();
|
||||
const portal = await createBuilder<{url: string}>(harness, account.token)
|
||||
.post('/premium/customer-portal')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(portal.url).toContain('billing.stripe.com');
|
||||
const user = await getUserRepository().findUnique(createUserID(BigInt(account.userId)));
|
||||
expect(user?.stripeCustomerId).toBeTruthy();
|
||||
expect(user?.stripeCustomerId).not.toBe('cus_from_old_account');
|
||||
expect(user?.stripeSubscriptionId).toBeNull();
|
||||
await createBuilder<{url: string}>(harness, account.token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: 'price_opgbpmonthly'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('accepts an operator price that does not match the buyer country', async () => {
|
||||
const token = await createPurchaser();
|
||||
await createBuilder<{url: string}>(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: 'price_opchfyearly', country_code: 'SE'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions[0]?.line_items?.[0]?.price).toBe('price_opchfyearly');
|
||||
});
|
||||
|
||||
test('never offers the localized card preapproval flow', async () => {
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/subscription/preapproval')
|
||||
.body({price_id: 'price_opsekmonthly', country_code: 'SE'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('rejects pix and upi for operator prices', async () => {
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: 'price_opgbpmonthly', payment_method: 'pix'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('names the configured premium tier in the checkout terms on a self-hosted instance', async () => {
|
||||
useOperatorBilling({terms_consent_required: true});
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/gift')
|
||||
.body({price_id: 'price_opgbpgiftmonth'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const message =
|
||||
stripeHandlers.spies.createdCheckoutSessions[0]?.custom_text?.terms_of_service_acceptance?.message;
|
||||
expect(message).toContain('Premium');
|
||||
expect(message).not.toContain('Plutonium');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe client', () => {
|
||||
test('is shared while the secret stays the same and rebuilt when it changes', () => {
|
||||
useOperatorBilling();
|
||||
const first = getStripeClient();
|
||||
expect(first).not.toBeNull();
|
||||
expect(getStripeClient()).toBe(first);
|
||||
setStoredBillingConfig(operatorBilling({stripe_secret_key: 'sk_test_operator_b'}));
|
||||
const second = getStripeClient();
|
||||
expect(second).not.toBeNull();
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
test('stays available on self-hosted after billing is disabled so subscriptions can be serviced', () => {
|
||||
useOperatorBilling({enabled: false});
|
||||
expect(isBillingActive()).toBe(false);
|
||||
expect(getStripeClient()).not.toBeNull();
|
||||
});
|
||||
|
||||
test('is unavailable on hosted when env billing is disabled', () => {
|
||||
const enabled = Config.stripe.enabled;
|
||||
Config.stripe.enabled = false;
|
||||
try {
|
||||
setStoredBillingConfig(operatorBilling({enabled: true}));
|
||||
expect(getStripeClient()).toBeNull();
|
||||
} finally {
|
||||
Config.stripe.enabled = enabled;
|
||||
}
|
||||
});
|
||||
|
||||
test('sends requests with the currently stored secret key', async () => {
|
||||
const authorizations: Array<string | null> = [];
|
||||
server.use(
|
||||
http.get('https://api.stripe.com/v1/prices/:id', ({request, params}) => {
|
||||
authorizations.push(request.headers.get('authorization'));
|
||||
return HttpResponse.json({id: params.id, object: 'price', unit_amount: 500, currency: 'gbp'});
|
||||
}),
|
||||
);
|
||||
const suffix = crypto.randomBytes(4).toString('hex');
|
||||
const pricesFor = (tag: string) => ({
|
||||
GBP: {
|
||||
monthly: `price_${tag}${suffix}monthly`,
|
||||
yearly: `price_${tag}${suffix}yearly`,
|
||||
gift_1_month: `price_${tag}${suffix}giftmonth`,
|
||||
gift_1_year: `price_${tag}${suffix}giftyear`,
|
||||
},
|
||||
});
|
||||
useOperatorBilling({stripe_secret_key: 'sk_test_rotate_a', prices: pricesFor('a')});
|
||||
await createBuilder(harness, '').get('/premium/price-ids').expect(HTTP_STATUS.OK).execute();
|
||||
setStoredBillingConfig(operatorBilling({stripe_secret_key: 'sk_test_rotate_b', prices: pricesFor('b')}));
|
||||
await createBuilder(harness, '').get('/premium/price-ids').expect(HTTP_STATUS.OK).execute();
|
||||
expect(authorizations.slice(0, 4).every((value) => value === 'Bearer sk_test_rotate_a')).toBe(true);
|
||||
expect(authorizations.slice(4)).toHaveLength(4);
|
||||
expect(authorizations.slice(4).every((value) => value === 'Bearer sk_test_rotate_b')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('premium purchase availability', () => {
|
||||
test('reports purchases disabled while billing is inactive', async () => {
|
||||
Config.stripe.prices = {};
|
||||
expect(isBillingActive()).toBe(false);
|
||||
const account = await createAccount();
|
||||
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
|
||||
.get('/premium/state')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(state.effective.premium_purchase_disabled).toBe(true);
|
||||
expect(state.billing.list_price_switch.reason).toBe('feature_unavailable');
|
||||
});
|
||||
|
||||
test('reports purchases enabled while billing is active', async () => {
|
||||
expect(isBillingActive()).toBe(true);
|
||||
const account = await createAccount();
|
||||
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
|
||||
.get('/premium/state')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(state.effective.premium_purchase_disabled).toBe(false);
|
||||
});
|
||||
|
||||
test('reports purchases disabled on a self-hosted instance where everyone is premium', async () => {
|
||||
Config.instance.selfHosted = true;
|
||||
setCachedInstancePremiumMode('everyone');
|
||||
setStoredBillingConfig(operatorBilling());
|
||||
expect(isBillingActive()).toBe(false);
|
||||
const account = await createAccount();
|
||||
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
|
||||
.get('/premium/state')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(state.effective.premium_purchase_disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
describe('webhook secret', () => {
|
||||
beforeEach(() => {
|
||||
setupSyncStripeWebhookWorker();
|
||||
Config.stripe.webhookSecret = ENV_WEBHOOK_SECRET;
|
||||
useOperatorBilling();
|
||||
});
|
||||
|
||||
async function sendWebhook(secret: string): Promise<number> {
|
||||
const {payload, timestamp} = createMockWebhookPayload({
|
||||
type: 'customer.created',
|
||||
data: {object: {id: `cus_operator_${crypto.randomBytes(4).toString('hex')}`}},
|
||||
});
|
||||
const {response} = await createBuilder(harness, '')
|
||||
.post('/stripe/webhook')
|
||||
.header('stripe-signature', signWebhook(payload, timestamp, secret))
|
||||
.header('content-type', 'application/json')
|
||||
.body(payload)
|
||||
.executeRaw();
|
||||
return response.status;
|
||||
}
|
||||
|
||||
test('verifies the signature with the stored webhook secret in the api and the worker', async () => {
|
||||
expect(await sendWebhook(STORED_WEBHOOK_SECRET)).toBe(200);
|
||||
});
|
||||
|
||||
test('rejects a signature made with the overridden env secret', async () => {
|
||||
expect(await sendWebhook(ENV_WEBHOOK_SECRET)).toBe(401);
|
||||
});
|
||||
|
||||
function signedJob(secret: string): {body: string; signature: string} {
|
||||
const {payload, timestamp} = createMockWebhookPayload({
|
||||
type: 'customer.created',
|
||||
data: {object: {id: `cus_operator_${crypto.randomBytes(4).toString('hex')}`}},
|
||||
});
|
||||
return {body: payload, signature: signWebhook(payload, timestamp, secret)};
|
||||
}
|
||||
|
||||
test('accepts a queued event signed with the previous secret in the worker after a rotation', async () => {
|
||||
setStoredBillingConfig(operatorBilling({stripe_webhook_secret: 'whsec_operator_rotated'}));
|
||||
await expect(processStripeWebhook(signedJob(STORED_WEBHOOK_SECRET), WORKER_HELPERS)).resolves.toBeUndefined();
|
||||
await expect(processStripeWebhook(signedJob('whsec_operator_rotated'), WORKER_HELPERS)).resolves.toBeUndefined();
|
||||
await expect(processStripeWebhook(signedJob('whsec_never_configured'), WORKER_HELPERS)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('retries instead of discarding a queued event when the self-hosted worker has no Stripe client', async () => {
|
||||
setWorkerDependenciesForTest({stripe: null});
|
||||
await expect(processStripeWebhook(signedJob(STORED_WEBHOOK_SECRET), WORKER_HELPERS)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('still discards a queued event on hosted when Stripe is not configured', async () => {
|
||||
Config.instance.selfHosted = false;
|
||||
setWorkerDependenciesForTest({stripe: null});
|
||||
await expect(processStripeWebhook(signedJob(ENV_WEBHOOK_SECRET), WORKER_HELPERS)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,443 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
|
||||
import {getInstanceConfigRepository, getLimitConfigService} from '@app/api/middleware/ServiceSingletons';
|
||||
import {getStoredBillingConfig, setStoredBillingConfig} from '@app/api/stripe/BillingConfigCache';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createStripeApiHandlers} from '@app/api/test/msw/handlers/StripeApiHandlers';
|
||||
import {server} from '@app/api/test/msw/server';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {WellKnownFluxerResponse} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
const OPERATOR_WEBHOOK_SECRET = 'whsec_operator_secret';
|
||||
|
||||
const USD_PRICES = {
|
||||
monthlyUsd: 'price_monthlyusd',
|
||||
yearlyUsd: 'price_yearlyusd',
|
||||
gift1MonthUsd: 'price_gift1monthusd',
|
||||
gift1YearUsd: 'price_gift1yearusd',
|
||||
monthlyEur: 'price_monthlyeur',
|
||||
yearlyEur: 'price_yearlyeur',
|
||||
gift1MonthEur: 'price_gift1montheur',
|
||||
gift1YearEur: 'price_gift1yeareur',
|
||||
};
|
||||
|
||||
type StripePrices = ReturnType<typeof getConfig>['stripe']['prices'];
|
||||
|
||||
interface GlobalState {
|
||||
selfHosted: boolean;
|
||||
premiumMode: ReturnType<typeof getCachedInstancePremiumMode>;
|
||||
storedBilling: ReturnType<typeof getStoredBillingConfig>;
|
||||
prices: StripePrices;
|
||||
}
|
||||
|
||||
function captureGlobalState(): GlobalState {
|
||||
const config = getConfig();
|
||||
return {
|
||||
selfHosted: config.instance.selfHosted,
|
||||
premiumMode: getCachedInstancePremiumMode(),
|
||||
storedBilling: getStoredBillingConfig(),
|
||||
prices: config.stripe.prices,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreGlobalState(state: GlobalState): void {
|
||||
const config = getConfig();
|
||||
config.instance.selfHosted = state.selfHosted;
|
||||
config.stripe.prices = state.prices;
|
||||
setCachedInstancePremiumMode(state.premiumMode);
|
||||
setStoredBillingConfig(state.storedBilling);
|
||||
}
|
||||
|
||||
function signWebhook(payload: string, secret: string): string {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const signature = crypto.createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex');
|
||||
return `t=${timestamp},v1=${signature}`;
|
||||
}
|
||||
|
||||
function webhookPayload(): string {
|
||||
return JSON.stringify({
|
||||
id: `evt_${crypto.randomBytes(8).toString('hex')}`,
|
||||
object: 'event',
|
||||
type: 'customer.created',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
data: {object: {id: 'cus_test_selfhosted'}},
|
||||
});
|
||||
}
|
||||
|
||||
async function setPremiumMode(mode: 'mirror' | 'everyone'): Promise<void> {
|
||||
await getLimitConfigService().updatePolicyConfig({premium_mode: mode});
|
||||
setCachedInstancePremiumMode(mode);
|
||||
}
|
||||
|
||||
async function generateGiftCode(harness: ApiTestHarness): Promise<string> {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.GIFT_CODES_GENERATE,
|
||||
]);
|
||||
const {codes} = await createBuilder<{codes: Array<string>}>(harness, admin.token)
|
||||
.post('/admin/gift-codes')
|
||||
.body({count: 1, duration_type: 'months', duration_quantity: 1})
|
||||
.execute();
|
||||
const code = codes[0]?.split('/').pop();
|
||||
if (!code) throw new Error('no gift code generated');
|
||||
return code;
|
||||
}
|
||||
|
||||
async function expectRoutesNotFound(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
routes: ReadonlyArray<readonly ['GET' | 'POST', string]>,
|
||||
): Promise<void> {
|
||||
for (const [method, path] of routes) {
|
||||
expect({path, ...(await readRouteStatus(harness, token, method, path))}).toEqual({
|
||||
path,
|
||||
status: HTTP_STATUS.NOT_FOUND,
|
||||
code: APIErrorCodes.NOT_FOUND,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function expectRoutesRegistered(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
routes: ReadonlyArray<readonly ['GET' | 'POST', string]>,
|
||||
): Promise<void> {
|
||||
for (const [method, path] of routes) {
|
||||
const result = await readRouteStatus(harness, token, method, path);
|
||||
expect({path, code: result.code}).not.toEqual({path, code: APIErrorCodes.NOT_FOUND});
|
||||
}
|
||||
}
|
||||
|
||||
async function postSignedWebhook(harness: ApiTestHarness, secret: string): Promise<number> {
|
||||
const payload = webhookPayload();
|
||||
const {response} = await createBuilderWithoutAuth(harness)
|
||||
.post('/stripe/webhook')
|
||||
.header('stripe-signature', signWebhook(payload, secret))
|
||||
.header('content-type', 'application/json')
|
||||
.body(payload)
|
||||
.executeRaw();
|
||||
return response.status;
|
||||
}
|
||||
|
||||
async function readRouteStatus(
|
||||
harness: ApiTestHarness,
|
||||
token: string | null,
|
||||
method: 'GET' | 'POST',
|
||||
path: string,
|
||||
): Promise<{status: number; code: string | undefined}> {
|
||||
const builder = token === null ? createBuilderWithoutAuth(harness) : createBuilder(harness, token);
|
||||
const request = method === 'GET' ? builder.get(path) : builder.post(path).body({});
|
||||
const {response, json} = await request.executeRaw();
|
||||
return {status: response.status, code: (json as {code?: string} | undefined)?.code};
|
||||
}
|
||||
|
||||
const PURCHASE_ROUTES: ReadonlyArray<['GET' | 'POST', string]> = [
|
||||
['POST', '/stripe/checkout/subscription'],
|
||||
['POST', '/stripe/checkout/subscription/preapproval'],
|
||||
['POST', '/stripe/checkout/subscription/preapproval/continue'],
|
||||
['POST', '/stripe/checkout/gift'],
|
||||
['GET', '/premium/price-ids'],
|
||||
];
|
||||
|
||||
const SERVICING_ROUTES: ReadonlyArray<['GET' | 'POST', string]> = [
|
||||
['GET', '/premium/current-subscription-price'],
|
||||
['POST', '/premium/customer-portal'],
|
||||
['POST', '/premium/grace/end'],
|
||||
['POST', '/premium/cancel-subscription'],
|
||||
['POST', '/premium/reactivate-subscription'],
|
||||
['POST', '/premium/change-subscription'],
|
||||
['POST', '/premium/cancel-pending-subscription-change'],
|
||||
];
|
||||
|
||||
const BILLING_ROUTES: ReadonlyArray<['GET' | 'POST', string]> = [...PURCHASE_ROUTES, ...SERVICING_ROUTES];
|
||||
|
||||
const GIFT_ROUTES: ReadonlyArray<['GET' | 'POST', string]> = [
|
||||
['GET', '/gifts/somegiftcode'],
|
||||
['POST', '/gifts/somegiftcode/redeem'],
|
||||
['GET', '/users/@me/gifts'],
|
||||
];
|
||||
|
||||
const HOSTED_ONLY_ROUTES: ReadonlyArray<['GET' | 'POST', string]> = [
|
||||
['POST', '/users/@me/age-verification'],
|
||||
['GET', '/premium/refund-eligibility'],
|
||||
['POST', '/premium/refund-latest'],
|
||||
['POST', '/premium/switch-to-list-price'],
|
||||
['POST', '/premium/visionary/rejoin'],
|
||||
];
|
||||
|
||||
describe('self-hosted premium routes', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let original: GlobalState;
|
||||
|
||||
beforeAll(async () => {
|
||||
original = captureGlobalState();
|
||||
getConfig().instance.selfHosted = true;
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
setStoredBillingConfig(null);
|
||||
setCachedInstancePremiumMode('everyone');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getConfig().stripe.prices = original.prices;
|
||||
setStoredBillingConfig(null);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
restoreGlobalState(original);
|
||||
});
|
||||
|
||||
describe('everyone mode', () => {
|
||||
test('answers every Stripe and gift route like an unregistered route', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const unregistered = await readRouteStatus(harness, account.token, 'GET', '/premium/not-a-route');
|
||||
expect(unregistered).toEqual({status: HTTP_STATUS.NOT_FOUND, code: APIErrorCodes.NOT_FOUND});
|
||||
for (const [method, path] of [
|
||||
...BILLING_ROUTES,
|
||||
...GIFT_ROUTES,
|
||||
...HOSTED_ONLY_ROUTES,
|
||||
['POST', '/stripe/webhook'] as const,
|
||||
]) {
|
||||
expect({path, ...(await readRouteStatus(harness, account.token, method, path))}).toEqual({
|
||||
path,
|
||||
...unregistered,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('answers before authentication runs', async () => {
|
||||
expect(await readRouteStatus(harness, null, 'GET', '/users/@me/gifts')).toEqual({
|
||||
status: HTTP_STATUS.NOT_FOUND,
|
||||
code: APIErrorCodes.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects admin gift code generation', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.GIFT_CODES_GENERATE,
|
||||
]);
|
||||
await createBuilder(harness, admin.token)
|
||||
.post('/admin/gift-codes')
|
||||
.body({count: 1, duration_type: 'months', duration_quantity: 1})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.FEATURE_NOT_AVAILABLE_SELF_HOSTED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('reports premium and Stripe as disabled in discovery', async () => {
|
||||
getConfig().stripe.prices = {...USD_PRICES};
|
||||
const discovery = await createBuilderWithoutAuth<WellKnownFluxerResponse>(harness)
|
||||
.get('/.well-known/fluxer')
|
||||
.execute();
|
||||
expect(discovery.features).toMatchObject({
|
||||
premium_enabled: false,
|
||||
stripe_enabled: false,
|
||||
stripe_serviceable: false,
|
||||
self_hosted: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mirror mode without billing', () => {
|
||||
beforeEach(async () => {
|
||||
await setPremiumMode('mirror');
|
||||
});
|
||||
|
||||
test('serves admin generated gift codes', async () => {
|
||||
const code = await generateGiftCode(harness);
|
||||
const gift = await createBuilderWithoutAuth<{code: string}>(harness).get(`/gifts/${code}`).execute();
|
||||
expect(gift.code).toBe(code);
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/gifts/${code}/redeem`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
const gifts = await createBuilder<Array<unknown>>(harness, account.token).get('/users/@me/gifts').execute();
|
||||
expect(gifts).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps purchase and hosted-only routes unavailable', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await expectRoutesNotFound(harness, account.token, [...PURCHASE_ROUTES, ...HOSTED_ONLY_ROUTES]);
|
||||
});
|
||||
|
||||
test('keeps servicing routes and the webhook available while a Stripe key is configured', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await expectRoutesRegistered(harness, account.token, SERVICING_ROUTES);
|
||||
expect(await postSignedWebhook(harness, 'whsec_test_fluxer')).toBe(HTTP_STATUS.OK);
|
||||
});
|
||||
|
||||
test('hides servicing routes and the webhook without a Stripe key', async () => {
|
||||
const config = getConfig();
|
||||
const secretKey = config.stripe.secretKey;
|
||||
config.stripe.secretKey = undefined;
|
||||
try {
|
||||
const account = await createTestAccount(harness);
|
||||
await expectRoutesNotFound(harness, account.token, [...SERVICING_ROUTES, ['POST', '/stripe/webhook'] as const]);
|
||||
const discovery = await createBuilderWithoutAuth<WellKnownFluxerResponse>(harness)
|
||||
.get('/.well-known/fluxer')
|
||||
.execute();
|
||||
expect(discovery.features).toMatchObject({premium_enabled: true, stripe_serviceable: false});
|
||||
} finally {
|
||||
config.stripe.secretKey = secretKey;
|
||||
}
|
||||
});
|
||||
|
||||
test('reports premium enabled and Stripe disabled in discovery', async () => {
|
||||
const discovery = await createBuilderWithoutAuth<WellKnownFluxerResponse>(harness)
|
||||
.get('/.well-known/fluxer')
|
||||
.execute();
|
||||
expect(discovery.features).toMatchObject({
|
||||
premium_enabled: true,
|
||||
stripe_enabled: false,
|
||||
stripe_serviceable: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mirror mode with billing configured', () => {
|
||||
beforeEach(async () => {
|
||||
await setPremiumMode('mirror');
|
||||
});
|
||||
|
||||
test('serves price ids from the env catalog', async () => {
|
||||
getConfig().stripe.prices = {...USD_PRICES};
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
const priceIds = await createBuilderWithoutAuth<Record<string, unknown>>(harness)
|
||||
.get('/premium/price-ids?country_code=US')
|
||||
.execute();
|
||||
expect(priceIds).toMatchObject({monthly: USD_PRICES.monthlyUsd, yearly: USD_PRICES.yearlyUsd});
|
||||
const discovery = await createBuilderWithoutAuth<WellKnownFluxerResponse>(harness)
|
||||
.get('/.well-known/fluxer')
|
||||
.execute();
|
||||
expect(discovery.features).toMatchObject({premium_enabled: true, stripe_enabled: true});
|
||||
});
|
||||
|
||||
test('verifies webhooks with the operator webhook secret', async () => {
|
||||
await getInstanceConfigRepository().setInstanceBillingConfig({
|
||||
enabled: true,
|
||||
stripe_webhook_secret: OPERATOR_WEBHOOK_SECRET,
|
||||
prices: {GBP: {monthly: 'price_monthlygbp', yearly: 'price_yearlygbp'}},
|
||||
});
|
||||
const missing = await createBuilderWithoutAuth(harness).post('/stripe/webhook').body('{}').executeRaw();
|
||||
expect(missing.response.status).toBe(HTTP_STATUS.BAD_REQUEST);
|
||||
const payload = webhookPayload();
|
||||
const accepted = await createBuilderWithoutAuth(harness)
|
||||
.post('/stripe/webhook')
|
||||
.header('stripe-signature', signWebhook(payload, OPERATOR_WEBHOOK_SECRET))
|
||||
.header('content-type', 'application/json')
|
||||
.body(payload)
|
||||
.executeRaw();
|
||||
expect(accepted.response.status).toBe(HTTP_STATUS.OK);
|
||||
const rejected = await createBuilderWithoutAuth(harness)
|
||||
.post('/stripe/webhook')
|
||||
.header('stripe-signature', signWebhook(payload, 'whsec_test_fluxer'))
|
||||
.header('content-type', 'application/json')
|
||||
.body(payload)
|
||||
.executeRaw();
|
||||
expect(rejected.response.status).toBe(HTTP_STATUS.UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('keeps servicing routes and the webhook available after billing is disabled', async () => {
|
||||
await getInstanceConfigRepository().setInstanceBillingConfig({
|
||||
enabled: false,
|
||||
stripe_webhook_secret: OPERATOR_WEBHOOK_SECRET,
|
||||
prices: {GBP: {monthly: 'price_monthlygbp', yearly: 'price_yearlygbp'}},
|
||||
});
|
||||
const account = await createTestAccount(harness);
|
||||
await expectRoutesNotFound(harness, account.token, PURCHASE_ROUTES);
|
||||
await expectRoutesRegistered(harness, account.token, SERVICING_ROUTES);
|
||||
expect(await postSignedWebhook(harness, OPERATOR_WEBHOOK_SECRET)).toBe(HTTP_STATUS.OK);
|
||||
const discovery = await createBuilderWithoutAuth<WellKnownFluxerResponse>(harness)
|
||||
.get('/.well-known/fluxer')
|
||||
.execute();
|
||||
expect(discovery.features).toMatchObject({
|
||||
premium_enabled: true,
|
||||
stripe_enabled: false,
|
||||
stripe_serviceable: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps hosted-only routes unavailable', async () => {
|
||||
getConfig().stripe.prices = {...USD_PRICES};
|
||||
const account = await createTestAccount(harness);
|
||||
for (const [method, path] of HOSTED_ONLY_ROUTES) {
|
||||
expect({path, ...(await readRouteStatus(harness, account.token, method, path))}).toEqual({
|
||||
path,
|
||||
status: HTTP_STATUS.NOT_FOUND,
|
||||
code: APIErrorCodes.NOT_FOUND,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hosted premium routes', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let original: GlobalState;
|
||||
|
||||
beforeAll(async () => {
|
||||
original = captureGlobalState();
|
||||
getConfig().instance.selfHosted = false;
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
setStoredBillingConfig(null);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
restoreGlobalState(original);
|
||||
});
|
||||
|
||||
test('keeps every Stripe and gift route registered regardless of premium mode', async () => {
|
||||
setCachedInstancePremiumMode('everyone');
|
||||
const account = await createTestAccount(harness);
|
||||
const routes = [...BILLING_ROUTES, ...GIFT_ROUTES, ...HOSTED_ONLY_ROUTES].filter(
|
||||
([, path]) => path !== '/users/@me/age-verification',
|
||||
);
|
||||
for (const [method, path] of routes) {
|
||||
const result = await readRouteStatus(harness, account.token, method, path);
|
||||
expect({path, code: result.code}).not.toEqual({path, code: APIErrorCodes.NOT_FOUND});
|
||||
}
|
||||
const webhook = await createBuilderWithoutAuth(harness).post('/stripe/webhook').body('{}').executeRaw();
|
||||
expect(webhook.response.status).toBe(HTTP_STATUS.BAD_REQUEST);
|
||||
});
|
||||
|
||||
test('serves admin generated gift codes', async () => {
|
||||
setCachedInstancePremiumMode('everyone');
|
||||
const code = await generateGiftCode(harness);
|
||||
const gift = await createBuilderWithoutAuth<{code: string}>(harness).get(`/gifts/${code}`).execute();
|
||||
expect(gift.code).toBe(code);
|
||||
});
|
||||
|
||||
test('reports premium enabled and Stripe from the configured catalog in discovery', async () => {
|
||||
const before = await createBuilderWithoutAuth<WellKnownFluxerResponse>(harness)
|
||||
.get('/.well-known/fluxer')
|
||||
.execute();
|
||||
expect(before.features).toMatchObject({premium_enabled: true, stripe_enabled: false, self_hosted: false});
|
||||
getConfig().stripe.prices = {...USD_PRICES};
|
||||
try {
|
||||
const after = await createBuilderWithoutAuth<WellKnownFluxerResponse>(harness)
|
||||
.get('/.well-known/fluxer')
|
||||
.execute();
|
||||
expect(after.features).toMatchObject({premium_enabled: true, stripe_enabled: true});
|
||||
} finally {
|
||||
getConfig().stripe.prices = original.prices;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
type EffectiveBillingConfig,
|
||||
getEffectiveBillingConfig,
|
||||
getOperatorCurrencyPreferences,
|
||||
} from '@app/api/stripe/BillingConfigCache';
|
||||
import {isEuEeaCountryCode} from '@fluxer/constants/src/EuropeanEconomicArea';
|
||||
import type {PremiumCurrency} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
|
||||
@@ -9,7 +14,17 @@ export function getCurrency(countryCode: string | null | undefined): Currency {
|
||||
return getCurrencyPreferences(countryCode)[0];
|
||||
}
|
||||
|
||||
export function getCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
|
||||
export function getCurrencyPreferences(
|
||||
countryCode: string | null | undefined,
|
||||
config: EffectiveBillingConfig = getEffectiveBillingConfig(),
|
||||
): Array<Currency> {
|
||||
if (config.catalogMode === 'operator') {
|
||||
return getOperatorCurrencyPreferences(countryCode, config);
|
||||
}
|
||||
return getEnvCurrencyPreferences(countryCode);
|
||||
}
|
||||
|
||||
function getEnvCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
|
||||
if (!countryCode) {
|
||||
return ['USD', 'EUR'];
|
||||
}
|
||||
@@ -43,8 +58,42 @@ export function getCurrencyPreferences(countryCode: string | null | undefined):
|
||||
|
||||
const GIFT_ELIGIBLE_LOCALIZED_CURRENCIES = new Set<Currency>(['DKK', 'NOK', 'SEK']);
|
||||
|
||||
export function getGiftCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
|
||||
return getCurrencyPreferences(countryCode).filter(
|
||||
const ENV_CATALOG_CURRENCIES = new Set<Currency>(['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY']);
|
||||
|
||||
const OPERATOR_CURRENCY_PATTERN = /^[A-Z]{3}$/;
|
||||
|
||||
export function getGiftCurrencyPreferences(
|
||||
countryCode: string | null | undefined,
|
||||
config: EffectiveBillingConfig = getEffectiveBillingConfig(),
|
||||
): Array<Currency> {
|
||||
if (config.catalogMode === 'operator') {
|
||||
return getOperatorCurrencyPreferences(countryCode, config).filter((currency) => {
|
||||
const set = config.prices[currency];
|
||||
return set?.gift_1_month != null && set.gift_1_year != null;
|
||||
});
|
||||
}
|
||||
return getEnvCurrencyPreferences(countryCode).filter(
|
||||
(currency) => currency === 'USD' || currency === 'EUR' || GIFT_ELIGIBLE_LOCALIZED_CURRENCIES.has(currency),
|
||||
);
|
||||
}
|
||||
|
||||
export function isLocalizedCurrency(
|
||||
currency: Currency,
|
||||
config: EffectiveBillingConfig = getEffectiveBillingConfig(),
|
||||
): boolean {
|
||||
return config.catalogMode === 'env' && currency !== 'USD' && currency !== 'EUR';
|
||||
}
|
||||
|
||||
export function normalizeCatalogCurrency(
|
||||
value: string | null | undefined,
|
||||
config: EffectiveBillingConfig = getEffectiveBillingConfig(),
|
||||
): Currency | null {
|
||||
const currency = value?.trim().toUpperCase();
|
||||
if (!currency) {
|
||||
return null;
|
||||
}
|
||||
if (config.catalogMode === 'operator') {
|
||||
return OPERATOR_CURRENCY_PATTERN.test(currency) ? currency : null;
|
||||
}
|
||||
return ENV_CATALOG_CURRENCIES.has(currency) ? currency : null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createGroupDmChannel,
|
||||
createGuild,
|
||||
getChannel,
|
||||
updateUserSettings,
|
||||
} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
@@ -39,6 +40,8 @@ describe('Voice Call Ringing', () => {
|
||||
const user2 = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
await updateUserSettings(harness, user1.token, {default_guilds_restricted: false});
|
||||
await updateUserSettings(harness, user2.token, {default_guilds_restricted: false});
|
||||
const guild = await createGuild(harness, user1.token, 'Mutual Guild');
|
||||
const invite = await createChannelInvite(harness, user1.token, guild.system_channel_id!);
|
||||
await acceptInvite(harness, user2.token, invite.code);
|
||||
|
||||
@@ -97,7 +97,7 @@ import type {OAuth2TokenRepository} from '@app/api/oauth/repositories/OAuth2Toke
|
||||
import type {ReadStateRepository} from '@app/api/read_state/ReadStateRepository';
|
||||
import type {ReadStateService} from '@app/api/read_state/ReadStateService';
|
||||
import type {ReportRepository} from '@app/api/report/ReportRepository';
|
||||
import {STRIPE_API_VERSION} from '@app/api/stripe/StripeApiVersion';
|
||||
import {getStripeClient} from '@app/api/stripe/StripeClient';
|
||||
import {PaymentRepository} from '@app/api/user/repositories/PaymentRepository';
|
||||
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
|
||||
import type {UserContactChangeLogService} from '@app/api/user/services/UserContactChangeLogService';
|
||||
@@ -113,7 +113,7 @@ import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import type {RateLimitService} from '@pkgs/rate_limit/src/RateLimitService';
|
||||
import type {IVirusScanService} from '@pkgs/virus_scan/src/IVirusScanService';
|
||||
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
|
||||
import Stripe from 'stripe';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
export interface WorkerDependencies {
|
||||
kvClient: IKVProvider;
|
||||
@@ -263,14 +263,6 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS
|
||||
ipInfoService,
|
||||
});
|
||||
const billingRepository = new BillingRepository(snowflakeService, kvClient);
|
||||
let stripe: Stripe | null = null;
|
||||
if (Config.stripe.enabled && Config.stripe.secretKey) {
|
||||
stripe = new Stripe(Config.stripe.secretKey, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
httpClient: Config.dev.testModeEnabled ? Stripe.createFetchHttpClient() : undefined,
|
||||
});
|
||||
Logger.info('Stripe initialized');
|
||||
}
|
||||
Logger.info('Worker dependencies initialized successfully');
|
||||
return {
|
||||
kvClient,
|
||||
@@ -325,6 +317,8 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS
|
||||
guildAuditLogService,
|
||||
contactChangeLogService,
|
||||
ncmecSubmissionService,
|
||||
stripe,
|
||||
get stripe() {
|
||||
return getStripeClient();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,9 +56,7 @@ function registerCronJobs(cron: CronScheduler, jobsStreamMaxAgeMs: number): void
|
||||
cron.upsert('processPremiumStateReconciliationQueue', 'processPremiumStateReconciliationQueue', {}, '0 * * * * *', {
|
||||
ledger: false,
|
||||
});
|
||||
if (!Config.instance.selfHosted) {
|
||||
cron.upsert('processExpiredPremiumSweep', 'processExpiredPremiumSweep', {}, '0 0 * * * *', {ledger: false});
|
||||
}
|
||||
cron.upsert('processExpiredPremiumSweep', 'processExpiredPremiumSweep', {}, '0 0 * * * *', {ledger: false});
|
||||
cron.upsert('processInactivityDeletions', 'processInactivityDeletions', {}, '0 0 */6 * * *', {ledger: false});
|
||||
cron.upsert('expireAttachments', 'expireAttachments', {}, '0 0 */12 * * *', {ledger: false});
|
||||
if (jobsStreamMaxAgeMs > 0 && jobsStreamMaxAgeMs <= JOBS_STREAM_MAX_AGE_MS) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
@@ -9,6 +8,7 @@ import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {isPremiumTieringActive} from '@app/api/stripe/BillingConfigCache';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {checkIsPremium, createPremiumClearPatch, shouldStripExpiredPremium} from '@app/api/user/UserHelpers';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
@@ -186,8 +186,8 @@ async function processExpiredPremiumSweepCore(deps: SweepDeps): Promise<SweepRes
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
};
|
||||
if (Config.instance.selfHosted) {
|
||||
Logger.debug('Skipping expired premium sweep on a self-hosted instance');
|
||||
if (!isPremiumTieringActive()) {
|
||||
Logger.debug('Skipping expired premium sweep because premium tiering is not active');
|
||||
return result;
|
||||
}
|
||||
Logger.debug('Starting expired premium sweep');
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {isBillingActive} from '@app/api/stripe/BillingConfigCache';
|
||||
import {canProvisionPremiumFromSubscriptionStatus} from '@app/api/stripe/StripeSubscriptionAccessPolicy';
|
||||
import {
|
||||
getInvoiceLatestLinePeriodEnd,
|
||||
@@ -274,7 +275,8 @@ async function reconcileUserPremiumStateFromStripe(params: {userId: UserID; stri
|
||||
);
|
||||
if (!subscription) {
|
||||
const hasStalePremium = user.premiumType === UserPremiumTypes.SUBSCRIPTION;
|
||||
const hasNonStripePremium = Config.instance.selfHosted || (user.premiumFlags & PremiumFlags.ENABLED_OVERRIDE) !== 0;
|
||||
const hasNonStripePremium =
|
||||
(Config.instance.selfHosted && !isBillingActive()) || (user.premiumFlags & PremiumFlags.ENABLED_OVERRIDE) !== 0;
|
||||
if (hasStalePremium && !hasNonStripePremium) {
|
||||
const patch: Partial<UserRow> = {};
|
||||
let effectivePremiumUntil = getEffectivePremiumUntil(user);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import {revokeAllAuthSessions} from '@app/api/auth/AuthSessionRevocation';
|
||||
import type {ISessionTerminator} from '@app/api/auth/ISessionTerminator';
|
||||
import {ProductRegistry} from '@app/api/stripe/ProductRegistry';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {getProductRegistry} from '@app/api/stripe/ProductRegistry';
|
||||
import {AgeVerificationService} from '@app/api/stripe/services/AgeVerificationService';
|
||||
import {StripeCheckoutService} from '@app/api/stripe/services/StripeCheckoutService';
|
||||
import {StripeGiftService} from '@app/api/stripe/services/StripeGiftService';
|
||||
@@ -22,11 +23,15 @@ const PayloadSchema = z.object({
|
||||
const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
|
||||
const {body, signature} = PayloadSchema.parse(payload);
|
||||
const deps = getWorkerDependencies();
|
||||
if (!deps.stripe) {
|
||||
const stripe = deps.stripe;
|
||||
if (!stripe) {
|
||||
if (Config.instance.selfHosted) {
|
||||
throw new Error('Stripe is not configured on this worker yet; retrying webhook event');
|
||||
}
|
||||
helpers.logger.warn('Stripe is not configured; discarding webhook event');
|
||||
return;
|
||||
}
|
||||
const productRegistry = new ProductRegistry();
|
||||
const productRegistry = getProductRegistry();
|
||||
const sessionTerminator: ISessionTerminator = {
|
||||
async terminateAllUserSessions(userId) {
|
||||
await revokeAllAuthSessions({users: deps.userRepository, gateway: deps.gatewayService}, userId);
|
||||
@@ -38,21 +43,16 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
|
||||
deps.guildRepository,
|
||||
deps.guildService,
|
||||
);
|
||||
const checkoutService = new StripeCheckoutService(
|
||||
deps.stripe,
|
||||
deps.userRepository,
|
||||
productRegistry,
|
||||
deps.cacheService,
|
||||
);
|
||||
const checkoutService = new StripeCheckoutService(stripe, deps.userRepository, productRegistry, deps.cacheService);
|
||||
const subscriptionService = new StripeSubscriptionService(
|
||||
deps.stripe,
|
||||
stripe,
|
||||
deps.userRepository,
|
||||
productRegistry,
|
||||
deps.cacheService,
|
||||
deps.gatewayService,
|
||||
);
|
||||
const giftService = new StripeGiftService(
|
||||
deps.stripe,
|
||||
stripe,
|
||||
deps.userRepository,
|
||||
deps.cacheService,
|
||||
deps.gatewayService,
|
||||
@@ -60,12 +60,15 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
|
||||
premiumService,
|
||||
subscriptionService,
|
||||
);
|
||||
const ageVerificationService = deps.stripe
|
||||
? new AgeVerificationService(deps.stripe, deps.userRepository, deps.gatewayService, deps.cacheService)
|
||||
: null;
|
||||
const refundService = new StripeRefundService(deps.stripe, deps.userRepository, subscriptionService);
|
||||
const ageVerificationService = new AgeVerificationService(
|
||||
stripe,
|
||||
deps.userRepository,
|
||||
deps.gatewayService,
|
||||
deps.cacheService,
|
||||
);
|
||||
const refundService = new StripeRefundService(stripe, deps.userRepository, subscriptionService);
|
||||
const webhookService = new StripeWebhookService(
|
||||
deps.stripe,
|
||||
stripe,
|
||||
checkoutService,
|
||||
deps.userRepository,
|
||||
deps.userCacheService,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {mapGiftDurationMonthsToFields} from '@app/api/models/GiftCode';
|
||||
import type {Payment} from '@app/api/models/Payment';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {ProductRegistry} from '@app/api/stripe/ProductRegistry';
|
||||
import {getProductRegistry} from '@app/api/stripe/ProductRegistry';
|
||||
import {extractId} from '@app/api/stripe/StripeUtils';
|
||||
import type {PaymentRepository} from '@app/api/user/repositories/PaymentRepository';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
@@ -71,7 +70,7 @@ async function reconcileCompletedGiftWithoutCode(payment: Payment, purchaser: Us
|
||||
return;
|
||||
}
|
||||
}
|
||||
const productRegistry = new ProductRegistry();
|
||||
const productRegistry = getProductRegistry();
|
||||
const productInfo = payment.priceId ? productRegistry.getProduct(payment.priceId) : null;
|
||||
if (!productInfo) {
|
||||
Logger.warn(
|
||||
@@ -159,7 +158,7 @@ async function reconcileStuckGiftPayment(payment: Payment, purchaser: User, stri
|
||||
}
|
||||
}
|
||||
if (!giftCode) {
|
||||
const productRegistry = new ProductRegistry();
|
||||
const productRegistry = getProductRegistry();
|
||||
const productInfo = payment.priceId ? productRegistry.getProduct(payment.priceId) : null;
|
||||
if (!productInfo) {
|
||||
Logger.warn(
|
||||
@@ -316,9 +315,6 @@ const reconcileUserPayments: WorkerTaskHandler = async (payload, helpers) => {
|
||||
helpers.logger.debug('Stripe is disabled, skipping user payment reconciliation');
|
||||
return;
|
||||
}
|
||||
if (!Config.stripe.enabled) {
|
||||
return;
|
||||
}
|
||||
const userIdStr = payload.userId as string;
|
||||
if (!userIdStr) {
|
||||
helpers.logger.warn({payload}, 'Payment reconciliation task missing userId');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
|
||||
import processExpiredPremiumSweep from '@app/api/worker/tasks/ProcessExpiredPremiumSweep';
|
||||
@@ -31,14 +32,21 @@ function createHelpers(): WorkerTaskHelpers {
|
||||
};
|
||||
}
|
||||
|
||||
async function withSelfHosted(selfHosted: boolean, callback: () => Promise<void>): Promise<void> {
|
||||
async function withSelfHosted(
|
||||
selfHosted: boolean,
|
||||
callback: () => Promise<void>,
|
||||
premiumMode: 'mirror' | 'everyone' = 'everyone',
|
||||
): Promise<void> {
|
||||
const config = getConfig();
|
||||
const originalSelfHosted = config.instance.selfHosted;
|
||||
const originalPremiumMode = getCachedInstancePremiumMode();
|
||||
try {
|
||||
config.instance.selfHosted = selfHosted;
|
||||
setCachedInstancePremiumMode(premiumMode);
|
||||
await callback();
|
||||
} finally {
|
||||
config.instance.selfHosted = originalSelfHosted;
|
||||
setCachedInstancePremiumMode(originalPremiumMode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +55,7 @@ describe('processExpiredPremiumSweep', () => {
|
||||
clearWorkerDependencies();
|
||||
});
|
||||
|
||||
test('scans no users on a self-hosted instance', async () => {
|
||||
test('scans no users on a self-hosted instance where everyone is premium', async () => {
|
||||
const harness = createHarness();
|
||||
|
||||
await withSelfHosted(true, async () => {
|
||||
@@ -57,6 +65,20 @@ describe('processExpiredPremiumSweep', () => {
|
||||
expect(harness.scanLimits).toEqual([]);
|
||||
});
|
||||
|
||||
test('scans users on a self-hosted instance in mirror mode', async () => {
|
||||
const harness = createHarness();
|
||||
|
||||
await withSelfHosted(
|
||||
true,
|
||||
async () => {
|
||||
await processExpiredPremiumSweep({}, createHelpers());
|
||||
},
|
||||
'mirror',
|
||||
);
|
||||
|
||||
expect(harness.scanLimits).toEqual([100]);
|
||||
});
|
||||
|
||||
test('scans users on a hosted instance', async () => {
|
||||
const harness = createHarness();
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<!--{{FLUXER_BOOTSTRAP}}-->
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var loc=window.location;if(loc.pathname==='/'){var target='/channels/@me';if(loc.search)target+=loc.search;if(loc.hash)target+=loc.hash;loc.replace(target);}}catch(e){}})();</script>
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var t=localStorage.getItem('theme');if(t){document.documentElement.classList.add('theme-'+t)}}catch{}})()</script>
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{if(typeof WebSocket!=='function')return;var t=localStorage.getItem('token');if(!t||t==='undefined'||t==='null')return;var b=window.__FLUXER_BOOTSTRAP__;var g=b&&b.instance&&b.instance.endpoints&&b.instance.endpoints.gateway;if(!g)return;var u=new URL(g);u.searchParams.set('v','1');u.searchParams.set('encoding','json');u.searchParams.set('compress','zstd-stream');u.searchParams.set('stream','1');var url=u.toString();var ws=new WebSocket(url);ws.binaryType='arraybuffer';var s={open:false,url:url,messages:[],startedAt:Date.now()};ws.onopen=function(){s.open=true};ws.onmessage=function(e){s.messages.push(e)};ws.onclose=ws.onerror=function(){window.__FLUXER_FAST_CONNECT__=null};window.__FLUXER_FAST_CONNECT__={ws:ws,state:s};setTimeout(function(){var h=window.__FLUXER_FAST_CONNECT__;if(h&&h.ws===ws){window.__FLUXER_FAST_CONNECT__=null;try{ws.close(1000,'Fast connect unclaimed')}catch(e){}}},30000)}catch(e){}})()</script>
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{if(typeof WebSocket!=='function')return;if(window.location.pathname.indexOf('/migrate/')===0)return;var t=localStorage.getItem('token');if(!t||t==='undefined'||t==='null')return;var b=window.__FLUXER_BOOTSTRAP__;var g=b&&b.instance&&b.instance.endpoints&&b.instance.endpoints.gateway;if(!g)return;var u=new URL(g);u.searchParams.set('v','1');u.searchParams.set('encoding','json');u.searchParams.set('compress','zstd-stream');u.searchParams.set('stream','1');var url=u.toString();var ws=new WebSocket(url);ws.binaryType='arraybuffer';var s={open:false,url:url,messages:[],startedAt:Date.now()};ws.onopen=function(){s.open=true};ws.onmessage=function(e){s.messages.push(e)};ws.onclose=ws.onerror=function(){window.__FLUXER_FAST_CONNECT__=null};window.__FLUXER_FAST_CONNECT__={ws:ws,state:s};setTimeout(function(){var h=window.__FLUXER_FAST_CONNECT__;if(h&&h.ws===ws){window.__FLUXER_FAST_CONNECT__=null;try{ws.close(1000,'Fast connect unclaimed')}catch(e){}}},30000)}catch(e){}})()</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -508,6 +508,13 @@ export default () => {
|
||||
priority: 43,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
i18n: {
|
||||
test: /[\\/]node_modules[\\/]@lingui[\\/]/,
|
||||
name: 'i18n',
|
||||
priority: 42,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
},
|
||||
reactAria: {
|
||||
test: /[\\/]node_modules[\\/]react-aria-components[\\/]/,
|
||||
name: 'react-aria',
|
||||
|
||||
@@ -557,10 +557,6 @@ const SKELETON_SURFACE_INVARIANTS: ReadonlyArray<SkeletonSurfaceInvariant> = [
|
||||
file: 'src/features/channel/components/MemberListSkeleton.tsx',
|
||||
counts: {'style={MEMBER_LIST_METRICS_STYLE}': 2},
|
||||
},
|
||||
{
|
||||
file: 'src/features/channel/components/textarea/InputWrapper.module.css',
|
||||
requires: ['.composerRoot:has(.statusTypingSlot)::before'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/GuildsLayout.module.css',
|
||||
requires: [
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'urlpattern-polyfill';
|
||||
import {bootstrapSyntheticHistory} from '@app/app/HistoryBootstrap';
|
||||
import reactiveI18n, {initI18n} from '@app/app/I18n';
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import {AppErrorBoundary} from '@app/features/app/components/AppErrorBoundary';
|
||||
import {ErrorFallback} from '@app/features/app/components/ErrorFallback';
|
||||
import type {DomainMigrationSide} from '@app/features/app/domain_migration/DomainMigrationCore';
|
||||
import {installSelfXssNotice} from '@app/features/devtools/utils/SelfXssNotice';
|
||||
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
|
||||
import {installLocaleSwitchWatchdog} from '@app/features/i18n/utils/LocaleSwitchWatchdog';
|
||||
import {installTranslationDomGuard} from '@app/features/i18n/utils/TranslationDomGuard';
|
||||
import {installScrollRestoration} from '@app/features/platform/components/router/ScrollRestoration';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {
|
||||
getFormattedClientInfo,
|
||||
getFormattedClientInfoSync,
|
||||
installFluxerConfigDebugApi,
|
||||
preloadClientInfo,
|
||||
} from '@app/features/platform/utils/ClientInfo';
|
||||
import {loadLazyModule} from '@app/features/platform/utils/LazyModuleLoader';
|
||||
import {scheduleNonLatinScriptFaces} from '@app/features/theme/fonts/ScriptFontLoader';
|
||||
import {installVoiceSubscriptionDebugApi} from '@app/features/voice/diagnostics/VoiceSubscriptionDebugApi';
|
||||
import {PASSKEY_BRIDGE_PATH} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import {i18n} from '@lingui/core';
|
||||
import type {ReactNode} from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
const logger = new Logger('index');
|
||||
|
||||
if (typeof window !== 'undefined' && window.history) {
|
||||
bootstrapSyntheticHistory();
|
||||
installScrollRestoration();
|
||||
}
|
||||
|
||||
installFluxerConfigDebugApi();
|
||||
installVoiceSubscriptionDebugApi();
|
||||
|
||||
function createRoot(): ReactDOM.Root {
|
||||
const container = document.getElementById('root');
|
||||
if (!container) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
return ReactDOM.createRoot(container);
|
||||
}
|
||||
|
||||
function mountRoot(content: ReactNode, dataFlxScope: string): void {
|
||||
installTranslationDomGuard();
|
||||
createRoot().render(
|
||||
<AppErrorBoundary
|
||||
fallback={(error) => (
|
||||
<AppI18nProvider i18n={i18n}>
|
||||
<ErrorFallback error={error ?? undefined} data-flx={`${dataFlxScope}.error-fallback`} />
|
||||
</AppI18nProvider>
|
||||
)}
|
||||
data-flx={`${dataFlxScope}.app-error-boundary`}
|
||||
>
|
||||
{content}
|
||||
</AppErrorBoundary>,
|
||||
);
|
||||
}
|
||||
|
||||
async function logClientInfo(): Promise<void> {
|
||||
try {
|
||||
const info = await getFormattedClientInfo();
|
||||
logger.info(`[CLIENT INFO] ${info}`);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load full client info:', error);
|
||||
logger.info(`[CLIENT INFO] ${getFormattedClientInfoSync()}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function preloadMarkdownParser(): Promise<void> {
|
||||
try {
|
||||
const {preloadMarkdownParserWasm} = await loadLazyModule(
|
||||
() => import('@app/features/messaging/utils/markdown/parser/MarkdownParserWasm'),
|
||||
);
|
||||
await preloadMarkdownParserWasm();
|
||||
} catch (error) {
|
||||
logger.warn('Failed to preload markdown parser:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapThemeStudio(): Promise<void> {
|
||||
const markdownParserReady = preloadMarkdownParser();
|
||||
const [{ThemeStudioStandaloneApp}, {setupHttp}, {default: AccountManager}] = await Promise.all([
|
||||
loadLazyModule(() => import('@app/features/theme_studio/ThemeStudioStandaloneApp')),
|
||||
loadLazyModule(() => import('@app/app/SetupHttp')),
|
||||
loadLazyModule(() => import('@app/features/auth/state/AccountManager')),
|
||||
]);
|
||||
await AccountManager.bootstrap();
|
||||
setupHttp();
|
||||
await markdownParserReady;
|
||||
mountRoot(
|
||||
<AppI18nProvider i18n={i18n}>
|
||||
<ThemeStudioStandaloneApp data-flx="index.render-theme-studio.theme-studio-standalone-app" />
|
||||
</AppI18nProvider>,
|
||||
'index.render-theme-studio',
|
||||
);
|
||||
}
|
||||
|
||||
export async function runPasskeyBridge(side: DomainMigrationSide): Promise<void> {
|
||||
const hash = window.location.hash;
|
||||
const opensInOwnTab = window.history.length === 1;
|
||||
window.history.replaceState(null, '', PASSKEY_BRIDGE_PATH);
|
||||
const [{PasskeyBridgePage}] = await Promise.all([
|
||||
loadLazyModule(() => import('@app/features/auth/passkey_migration/PasskeyBridgePage')),
|
||||
initI18n(),
|
||||
]);
|
||||
mountRoot(
|
||||
<AppI18nProvider i18n={i18n}>
|
||||
<PasskeyBridgePage
|
||||
side={side}
|
||||
hash={hash}
|
||||
opensInOwnTab={opensInOwnTab}
|
||||
data-flx="index.passkey-bridge.passkey-bridge-page"
|
||||
/>
|
||||
</AppI18nProvider>,
|
||||
'index.passkey-bridge',
|
||||
);
|
||||
}
|
||||
|
||||
async function probeSignedOutDomainMigration(): Promise<void> {
|
||||
try {
|
||||
const {probeSignedOutDeviceEnrollment} = await loadLazyModule(
|
||||
() => import('@app/features/app/domain_migration/DomainMigrationTrigger'),
|
||||
);
|
||||
probeSignedOutDeviceEnrollment();
|
||||
} catch (error) {
|
||||
logger.warn('Failed to start the signed-out domain migration probe:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapApp(): Promise<void> {
|
||||
const markdownParserReady = preloadMarkdownParser();
|
||||
const [
|
||||
{App},
|
||||
{setupHttp},
|
||||
{default: CaptchaInterceptor},
|
||||
{initializeEmojiParser},
|
||||
{registerServiceWorker},
|
||||
{default: AccountManager},
|
||||
{default: ChannelDisplayName},
|
||||
_channelFrecency,
|
||||
_geoIp,
|
||||
{default: Keybind},
|
||||
{default: NewDeviceMonitoring},
|
||||
{default: Notification},
|
||||
{default: QuickSwitcher},
|
||||
_runtimeConfig,
|
||||
{default: StatusPage},
|
||||
{installMigratedDeviceRemap},
|
||||
] = await Promise.all([
|
||||
loadLazyModule(() => import('@app/app/App')),
|
||||
loadLazyModule(() => import('@app/app/SetupHttp')),
|
||||
loadLazyModule(() => import('@app/features/auth/components/CaptchaInterceptor')),
|
||||
loadLazyModule(() => import('@app/features/messaging/utils/markdown/EmojiProviderSetup')),
|
||||
loadLazyModule(() => import('@app/features/platform/service_worker/Register')),
|
||||
loadLazyModule(() => import('@app/features/auth/state/AccountManager')),
|
||||
loadLazyModule(() => import('@app/features/channel/state/ChannelDisplayName')),
|
||||
loadLazyModule(() => import('@app/features/channel/state/ChannelFrecency')),
|
||||
loadLazyModule(() => import('@app/features/app/state/GeoIP')),
|
||||
loadLazyModule(() => import('@app/features/input/state/InputKeybind')),
|
||||
loadLazyModule(() => import('@app/features/auth/state/NewDeviceMonitoring')),
|
||||
loadLazyModule(() => import('@app/features/ui/state/Notification')),
|
||||
loadLazyModule(() => import('@app/features/search/state/QuickSwitcher')),
|
||||
loadLazyModule(() => import('@app/features/app/state/RuntimeConfig')),
|
||||
loadLazyModule(() => import('@app/features/user/state/StatusPage')),
|
||||
loadLazyModule(() => import('@app/features/app/domain_migration/DomainMigrationDeviceRemap')),
|
||||
]);
|
||||
void preloadClientInfo();
|
||||
QuickSwitcher.setI18n(reactiveI18n);
|
||||
ChannelDisplayName.setI18n(reactiveI18n);
|
||||
Keybind.setI18n(reactiveI18n);
|
||||
NewDeviceMonitoring.setI18n(reactiveI18n);
|
||||
Notification.setI18n(reactiveI18n);
|
||||
CaptchaInterceptor.setI18n(reactiveI18n);
|
||||
void StatusPage.checkIncidents();
|
||||
StatusPage.startPolling();
|
||||
await Promise.all([AccountManager.bootstrap(), installMigratedDeviceRemap()]);
|
||||
if (AccountManager.currentUserId === null) {
|
||||
void probeSignedOutDomainMigration();
|
||||
}
|
||||
setupHttp();
|
||||
initializeEmojiParser();
|
||||
await markdownParserReady;
|
||||
mountRoot(<App data-flx="index.bootstrap.app" />, 'index.bootstrap');
|
||||
QuickSwitcher.preloadModal();
|
||||
registerServiceWorker();
|
||||
}
|
||||
|
||||
export async function runApp(): Promise<void> {
|
||||
scheduleNonLatinScriptFaces();
|
||||
await initI18n();
|
||||
installLocaleSwitchWatchdog();
|
||||
installSelfXssNotice();
|
||||
void logClientInfo();
|
||||
if (window.location.pathname === Routes.THEME_STUDIO) {
|
||||
await bootstrapThemeStudio();
|
||||
} else {
|
||||
await bootstrapApp();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {initI18n} from '@app/app/I18n';
|
||||
import {BootstrapErrorScreen} from '@app/features/app/components/BootstrapErrorScreen';
|
||||
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {i18n} from '@lingui/core';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
const logger = new Logger('index');
|
||||
|
||||
export async function reportBootstrapError(error: unknown): Promise<void> {
|
||||
const normalized = error instanceof Error ? error : new Error(String(error));
|
||||
logger.error('Failed to bootstrap app:', normalized);
|
||||
const container = document.getElementById('root');
|
||||
if (!container) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
await initI18n();
|
||||
ReactDOM.createRoot(container).render(
|
||||
<AppI18nProvider i18n={i18n}>
|
||||
<BootstrapErrorScreen error={normalized} data-flx="index.bootstrap-error-screen" />
|
||||
</AppI18nProvider>,
|
||||
);
|
||||
}
|
||||
@@ -132,12 +132,9 @@ var {
|
||||
--composer-action-gap: 0.25rem;
|
||||
--composer-surface-color: var(--background-secondary-lighter);
|
||||
--composer-status-line-height: 1.125rem;
|
||||
--composer-status-row-height: 1.5rem;
|
||||
--composer-status-safe-gap: 0.5rem;
|
||||
--composer-status-safe-area: calc(
|
||||
var(--composer-status-line-height) +
|
||||
var(--composer-status-safe-gap) +
|
||||
var(--composer-status-safe-gap)
|
||||
);
|
||||
--composer-status-safe-area: calc(var(--composer-status-row-height) + var(--composer-status-safe-gap));
|
||||
--guild-list-item-box-size: 3rem;
|
||||
--guild-list-item-gap: 0.375rem;
|
||||
--guild-list-item-target-size: calc(var(--guild-list-item-box-size) + var(--guild-list-item-gap));
|
||||
@@ -173,7 +170,7 @@ var {
|
||||
--textarea-min-height: var(--footer-box-height);
|
||||
--textarea-padding-y: var(--footer-box-padding-y);
|
||||
--composer-box-inset: max(0rem, calc((var(--input-container-min-height) - var(--textarea-min-height)) / 2));
|
||||
--messages-bottom-clearance: var(--composer-status-safe-area);
|
||||
--messages-bottom-clearance: var(--composer-status-row-height);
|
||||
--composer-box-inset-inline: min(
|
||||
var(--footer-box-inset),
|
||||
var(--chat-horizontal-padding, var(--chat-horizontal-padding-default))
|
||||
|
||||
@@ -18,6 +18,7 @@ import {createRoute} from '@app/features/platform/components/router/RouterBuilde
|
||||
import type {RouteConfig, RouteContext} from '@app/features/platform/components/router/RouterTypes';
|
||||
import {Redirect} from '@app/features/platform/components/router/RouterTypes';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {i18n} from '@lingui/core';
|
||||
|
||||
const AuthorizeIPPage = createAuthRoutePage(
|
||||
@@ -388,5 +389,5 @@ export const authRouteTree = authLayoutRoute.addChildren([
|
||||
authorizeIPRoute,
|
||||
pendingRoute,
|
||||
reportRoute,
|
||||
...(RuntimeConfig.isSelfHosted() ? [] : [giftRegisterRoute, giftLoginRoute]),
|
||||
...(shouldShowPremiumFeatures() ? [giftRegisterRoute, giftLoginRoute] : []),
|
||||
]);
|
||||
|
||||
@@ -7,9 +7,12 @@ import {
|
||||
PRODUCT_NAME,
|
||||
} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import AppStorage, {PRESERVED_RESET_STORAGE_KEYS} from '@app/features/platform/state/PersistentStorage';
|
||||
import AppStorage, {
|
||||
PRESERVED_RESET_STORAGE_KEY_PREFIXES,
|
||||
PRESERVED_RESET_STORAGE_KEYS,
|
||||
} from '@app/features/platform/state/PersistentStorage';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {FluxerIcon} from '@app/features/ui/components/icons/FluxerIcon';
|
||||
import {APPLICATION_ICON_DESCRIPTOR, FluxerIconMark} from '@app/features/ui/components/icons/FluxerIconMark';
|
||||
import {ExternalUrls} from '@fluxer/constants/src/ExternalUrls';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
@@ -25,12 +28,16 @@ export const BootstrapErrorScreen: React.FC<BootstrapErrorScreenProps> = ({error
|
||||
window.location.reload();
|
||||
}, []);
|
||||
const handleReset = useCallback(() => {
|
||||
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS);
|
||||
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS, PRESERVED_RESET_STORAGE_KEY_PREFIXES);
|
||||
window.location.reload();
|
||||
}, []);
|
||||
return (
|
||||
<div className={styles.errorFallbackContainer} data-flx="app.bootstrap-error-screen.error-fallback-container">
|
||||
<FluxerIcon className={styles.errorFallbackIcon} data-flx="app.bootstrap-error-screen.error-fallback-icon" />
|
||||
<FluxerIconMark
|
||||
aria-label={i18n._(APPLICATION_ICON_DESCRIPTOR, {productName: PRODUCT_NAME})}
|
||||
className={styles.errorFallbackIcon}
|
||||
data-flx="app.bootstrap-error-screen.error-fallback-icon"
|
||||
/>
|
||||
<div className={styles.errorFallbackContent} data-flx="app.bootstrap-error-screen.error-fallback-content">
|
||||
<h1 className={styles.errorFallbackTitle} data-flx="app.bootstrap-error-screen.error-fallback-title">
|
||||
<Trans>Failed to start</Trans>
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
import errorFallbackStyles from '@app/features/app/components/ErrorFallback.module.css';
|
||||
import {NativeTitlebar} from '@app/features/app/components/layout/NativeTitlebar';
|
||||
import {useNativePlatform} from '@app/features/app/hooks/useNativePlatform';
|
||||
import AppStorage, {PRESERVED_RESET_STORAGE_KEYS} from '@app/features/platform/state/PersistentStorage';
|
||||
import AppStorage, {
|
||||
PRESERVED_RESET_STORAGE_KEY_PREFIXES,
|
||||
PRESERVED_RESET_STORAGE_KEYS,
|
||||
} from '@app/features/platform/state/PersistentStorage';
|
||||
import {ensureLatestAssets} from '@app/features/platform/types/Versioning';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
@@ -154,7 +157,7 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({error}) => {
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS);
|
||||
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS, PRESERVED_RESET_STORAGE_KEY_PREFIXES);
|
||||
location.reload();
|
||||
}}
|
||||
variant="danger"
|
||||
|
||||
@@ -92,3 +92,17 @@
|
||||
.comparisonTableContainer {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.redeemSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.redeemDescription {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.375rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {SelfServeRefundSection} from '@app/features/app/components/dialogs/compo
|
||||
import {SubscriptionCard} from '@app/features/app/components/dialogs/components/plutonium/SubscriptionCard';
|
||||
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import GeoIP from '@app/features/app/state/GeoIP';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
||||
import * as PremiumCommands from '@app/features/premium/commands/PremiumCommands';
|
||||
@@ -29,6 +30,13 @@ import {
|
||||
FREE_VS_PREMIUM_DESCRIPTOR,
|
||||
VERIFY_EMAIL_TO_PURCHASE_PREMIUM_DESCRIPTOR,
|
||||
} from '@app/features/premium/utils/PremiumMessageDescriptors';
|
||||
import {
|
||||
areGiftPurchasesAvailable,
|
||||
arePremiumPurchasesAvailable,
|
||||
canServiceStripeSubscriptions,
|
||||
shouldShowPremiumFeatures,
|
||||
} from '@app/features/premium/utils/PremiumUtils';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
@@ -97,6 +105,13 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
|
||||
const isClaimed = currentUser?.isClaimed() ?? false;
|
||||
const isEmailVerified = currentUser?.verified === true;
|
||||
const purchaseDisabled = !isClaimed || !isEmailVerified;
|
||||
const userPurchaseDisabled = premiumState?.effective.premium_purchase_disabled === true;
|
||||
const purchasesAvailable = arePremiumPurchasesAvailable(userPurchaseDisabled);
|
||||
const giftPurchasesAvailable = areGiftPurchasesAvailable(priceIds, userPurchaseDisabled);
|
||||
const hasBillingRelationship = subscriptionStatus.hasEverPurchased || premiumState?.billing.subscription != null;
|
||||
const billingUnavailable =
|
||||
!shouldShowPremiumFeatures() ||
|
||||
(!arePremiumPurchasesAvailable() && !(hasBillingRelationship && canServiceStripeSubscriptions()));
|
||||
const purchaseDisabledTooltip = !isClaimed
|
||||
? i18n._(CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME})
|
||||
: i18n._(VERIFY_EMAIL_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME});
|
||||
@@ -162,16 +177,18 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
|
||||
return (
|
||||
<div className={styles.giftModeContainer} data-flx="app.plutonium-content.gift-mode-container">
|
||||
<PlutoniumUpsellBanner data-flx="app.plutonium-content.plutonium-upsell-banner" />
|
||||
<GiftSection
|
||||
giftSectionRef={giftSectionRef}
|
||||
giftMonthlyPrice={giftMonthlyPrice}
|
||||
giftYearlyPrice={giftYearlyPrice}
|
||||
loadingCheckout={loadingCheckout}
|
||||
handleSelectPlan={handleSelectPlanGuarded}
|
||||
purchaseDisabled={purchaseDisabled}
|
||||
purchaseDisabledTooltip={purchaseDisabledTooltip}
|
||||
data-flx="app.plutonium-content.gift-section"
|
||||
/>
|
||||
{giftPurchasesAvailable && (
|
||||
<GiftSection
|
||||
giftSectionRef={giftSectionRef}
|
||||
giftMonthlyPrice={giftMonthlyPrice}
|
||||
giftYearlyPrice={giftYearlyPrice}
|
||||
loadingCheckout={loadingCheckout}
|
||||
handleSelectPlan={handleSelectPlanGuarded}
|
||||
purchaseDisabled={purchaseDisabled}
|
||||
purchaseDisabledTooltip={purchaseDisabledTooltip}
|
||||
data-flx="app.plutonium-content.gift-section"
|
||||
/>
|
||||
)}
|
||||
<div ref={perksSectionRef} data-flx="app.plutonium-content.div">
|
||||
<section className={styles.perksSection} data-flx="app.plutonium-content.perks-section">
|
||||
<SectionHeader
|
||||
@@ -246,14 +263,17 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
|
||||
handleCommunityButtonClick={handleCommunityButtonClick}
|
||||
purchaseDisabled={purchaseDisabled}
|
||||
purchaseDisabledTooltip={purchaseDisabledTooltip}
|
||||
billingUnavailable={billingUnavailable}
|
||||
data-flx="app.plutonium-content.subscription-card"
|
||||
/>
|
||||
<div className={styles.disclaimerContainer} data-flx="app.plutonium-content.disclaimer-container">
|
||||
<PurchaseDisclaimer align="center" isPremium data-flx="app.plutonium-content.purchase-disclaimer" />
|
||||
</div>
|
||||
{purchasesAvailable && (
|
||||
<div className={styles.disclaimerContainer} data-flx="app.plutonium-content.disclaimer-container">
|
||||
<PurchaseDisclaimer align="center" isPremium data-flx="app.plutonium-content.purchase-disclaimer" />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{subscriptionStatus.hasEverPurchased && (
|
||||
{subscriptionStatus.hasEverPurchased && !billingUnavailable && (
|
||||
<>
|
||||
<PurchaseHistorySection
|
||||
premiumState={premiumState}
|
||||
@@ -261,16 +281,37 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
|
||||
handleOpenCustomerPortal={handleOpenCustomerPortal}
|
||||
data-flx="app.plutonium-content.purchase-history-section"
|
||||
/>
|
||||
<SelfServeRefundSection
|
||||
eligibility={premiumState?.billing.refund_eligibility ?? null}
|
||||
refreshPremiumState={() => PremiumCommands.refreshPremiumState(countryCode ?? undefined)}
|
||||
data-flx="app.plutonium-content.self-serve-refund-section"
|
||||
/>
|
||||
{!RuntimeConfig.isSelfHosted() && (
|
||||
<SelfServeRefundSection
|
||||
eligibility={premiumState?.billing.refund_eligibility ?? null}
|
||||
refreshPremiumState={() => PremiumCommands.refreshPremiumState(countryCode ?? undefined)}
|
||||
data-flx="app.plutonium-content.self-serve-refund-section"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!subscriptionStatus.shouldShowPremiumCard ? (
|
||||
{!purchasesAvailable ? (
|
||||
!subscriptionStatus.shouldShowPremiumCard && (
|
||||
<section className={styles.redeemSection} data-flx="app.plutonium-content.redeem-section">
|
||||
<p className={styles.redeemDescription} data-flx="app.plutonium-content.redeem-description">
|
||||
<Trans comment="Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.">
|
||||
Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}.
|
||||
</Trans>
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
small
|
||||
onClick={navigateToRedeemGift}
|
||||
data-flx="app.plutonium-content.redeem-section.button.navigate-to-redeem-gift"
|
||||
>
|
||||
<Trans comment="Billing button for entering a premium gift code.">Redeem gift code</Trans>
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
) : !subscriptionStatus.shouldShowPremiumCard ? (
|
||||
<PricingSection
|
||||
isGiftMode={isGiftMode}
|
||||
isGiftMode={isGiftMode && giftPurchasesAvailable}
|
||||
giftPurchasesAvailable={giftPurchasesAvailable}
|
||||
setIsGiftMode={setIsGiftMode}
|
||||
monthlyPrice={monthlyPrice}
|
||||
yearlyPrice={yearlyPrice}
|
||||
@@ -283,16 +324,18 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
|
||||
data-flx="app.plutonium-content.pricing-section"
|
||||
/>
|
||||
) : (
|
||||
<GiftSection
|
||||
giftSectionRef={giftSectionRef}
|
||||
giftMonthlyPrice={giftMonthlyPrice}
|
||||
giftYearlyPrice={giftYearlyPrice}
|
||||
loadingCheckout={loadingCheckout}
|
||||
handleSelectPlan={handleSelectPlanGuarded}
|
||||
purchaseDisabled={purchaseDisabled}
|
||||
purchaseDisabledTooltip={purchaseDisabledTooltip}
|
||||
data-flx="app.plutonium-content.gift-section--2"
|
||||
/>
|
||||
giftPurchasesAvailable && (
|
||||
<GiftSection
|
||||
giftSectionRef={giftSectionRef}
|
||||
giftMonthlyPrice={giftMonthlyPrice}
|
||||
giftYearlyPrice={giftYearlyPrice}
|
||||
loadingCheckout={loadingCheckout}
|
||||
handleSelectPlan={handleSelectPlanGuarded}
|
||||
purchaseDisabled={purchaseDisabled}
|
||||
purchaseDisabledTooltip={purchaseDisabledTooltip}
|
||||
data-flx="app.plutonium-content.gift-section--2"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div ref={perksSectionRef} data-flx="app.plutonium-content.div--2">
|
||||
<section className={styles.perksSection} data-flx="app.plutonium-content.perks-section--2">
|
||||
@@ -308,9 +351,9 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{!subscriptionStatus.isPremium && (
|
||||
{!subscriptionStatus.isPremium && purchasesAvailable && (
|
||||
<BottomCTASection
|
||||
isGiftMode={isGiftMode}
|
||||
isGiftMode={isGiftMode && giftPurchasesAvailable}
|
||||
monthlyPrice={monthlyPrice}
|
||||
yearlyPrice={yearlyPrice}
|
||||
giftMonthlyPrice={giftMonthlyPrice}
|
||||
|
||||
@@ -3,28 +3,68 @@
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import styles from '@app/features/app/components/dialogs/components/PurchaseDisclaimer.module.css';
|
||||
import {ExternalLink} from '@app/features/app/components/shared/ExternalLink';
|
||||
import {PAYMENT_PROVIDER_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
|
||||
export const PurchaseDisclaimer = observer(
|
||||
({isPremium = false, align = 'center'}: {isPremium?: boolean; align?: 'left' | 'center'}) => {
|
||||
const terms = (
|
||||
<ExternalLink href={Routes.terms()} data-flx="app.purchase-disclaimer.external-link">
|
||||
const selfHosted = RuntimeConfig.isSelfHosted();
|
||||
const termsUrl = selfHosted ? RuntimeConfig.termsUrl : Routes.terms();
|
||||
const privacyUrl = selfHosted ? RuntimeConfig.privacyUrl : Routes.privacy();
|
||||
const terms = termsUrl ? (
|
||||
<ExternalLink href={termsUrl} data-flx="app.purchase-disclaimer.external-link">
|
||||
<Trans>Terms of service</Trans>
|
||||
</ExternalLink>
|
||||
);
|
||||
const privacy = (
|
||||
<ExternalLink href={Routes.privacy()} data-flx="app.purchase-disclaimer.external-link--2">
|
||||
) : null;
|
||||
const privacy = privacyUrl ? (
|
||||
<ExternalLink href={privacyUrl} data-flx="app.purchase-disclaimer.external-link--2">
|
||||
<Trans>Privacy policy</Trans>
|
||||
</ExternalLink>
|
||||
);
|
||||
return (
|
||||
<p
|
||||
className={clsx(styles.disclaimer, align === 'center' ? styles.center : styles.left)}
|
||||
data-flx="app.purchase-disclaimer.disclaimer"
|
||||
>
|
||||
{isPremium ? (
|
||||
) : null;
|
||||
const termsOnly = termsUrl ? (
|
||||
isPremium ? (
|
||||
<Trans comment="Purchase disclaimer when the instance links only its terms of service. The linked text is the terms of service document.">
|
||||
By purchasing, you agreed to our{' '}
|
||||
<ExternalLink href={termsUrl} data-flx="app.purchase-disclaimer.external-link--terms-only">
|
||||
Terms of service
|
||||
</ExternalLink>
|
||||
.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans comment="Purchase disclaimer when the instance links only its terms of service. The linked text is the terms of service document.">
|
||||
By purchasing, you agree to our{' '}
|
||||
<ExternalLink href={termsUrl} data-flx="app.purchase-disclaimer.external-link--terms-only">
|
||||
Terms of service
|
||||
</ExternalLink>
|
||||
.
|
||||
</Trans>
|
||||
)
|
||||
) : null;
|
||||
const privacyOnly = privacyUrl ? (
|
||||
isPremium ? (
|
||||
<Trans comment="Purchase disclaimer when the instance links only its privacy policy. The linked text is the privacy policy document.">
|
||||
By purchasing, you agreed to our{' '}
|
||||
<ExternalLink href={privacyUrl} data-flx="app.purchase-disclaimer.external-link--privacy-only">
|
||||
Privacy policy
|
||||
</ExternalLink>
|
||||
.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans comment="Purchase disclaimer when the instance links only its privacy policy. The linked text is the privacy policy document.">
|
||||
By purchasing, you agree to our{' '}
|
||||
<ExternalLink href={privacyUrl} data-flx="app.purchase-disclaimer.external-link--privacy-only">
|
||||
Privacy policy
|
||||
</ExternalLink>
|
||||
.
|
||||
</Trans>
|
||||
)
|
||||
) : null;
|
||||
const agreement =
|
||||
terms && privacy ? (
|
||||
isPremium ? (
|
||||
<Trans>
|
||||
By purchasing, you agreed to our {terms} and {privacy}.
|
||||
</Trans>
|
||||
@@ -32,13 +72,29 @@ export const PurchaseDisclaimer = observer(
|
||||
<Trans>
|
||||
By purchasing, you agree to our {terms} and {privacy}.
|
||||
</Trans>
|
||||
)}{' '}
|
||||
<Trans>
|
||||
Self-serve refunds available within 3 days of payment, once every 30 days. Refunding a subscription cancels
|
||||
it. EU/EEA buyers waive the 14-day right of withdrawal at checkout to access content immediately. Use the
|
||||
in-app refund button instead of a chargeback. Chargebacks can permanently restrict your account. Stripe
|
||||
handles payment securely. We never see your full card number.
|
||||
</Trans>
|
||||
)
|
||||
) : (
|
||||
(termsOnly ?? privacyOnly)
|
||||
);
|
||||
return (
|
||||
<p
|
||||
className={clsx(styles.disclaimer, align === 'center' ? styles.center : styles.left)}
|
||||
data-flx="app.purchase-disclaimer.disclaimer"
|
||||
>
|
||||
{agreement}
|
||||
{agreement ? ' ' : null}
|
||||
{selfHosted ? (
|
||||
<Trans comment="Purchase disclaimer on a self-hosted instance. {PAYMENT_PROVIDER_NAME} is the payment processor name.">
|
||||
{PAYMENT_PROVIDER_NAME} handles payment securely.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Self-serve refunds available within 3 days of payment, once every 30 days. Refunding a subscription cancels
|
||||
it. EU/EEA buyers waive the 14-day right of withdrawal at checkout to access content immediately. Use the
|
||||
in-app refund button instead of a chargeback. Chargebacks can permanently restrict your account. Stripe
|
||||
handles payment securely. We never see your full card number.
|
||||
</Trans>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
|
||||
+23
-19
@@ -70,6 +70,7 @@ const MESSAGE_1_MONTH_GIFT_DESCRIPTOR = msg({
|
||||
|
||||
interface PricingSectionProps {
|
||||
isGiftMode: boolean;
|
||||
giftPurchasesAvailable?: boolean;
|
||||
setIsGiftMode: (value: boolean) => void;
|
||||
monthlyPrice: string;
|
||||
yearlyPrice: string;
|
||||
@@ -84,6 +85,7 @@ interface PricingSectionProps {
|
||||
export const PricingSection: React.FC<PricingSectionProps> = observer(
|
||||
({
|
||||
isGiftMode,
|
||||
giftPurchasesAvailable = true,
|
||||
setIsGiftMode,
|
||||
monthlyPrice,
|
||||
yearlyPrice,
|
||||
@@ -100,25 +102,27 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
|
||||
i18n._(CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME});
|
||||
return (
|
||||
<section className={styles.section} data-flx="app.plutonium.pricing-section.section">
|
||||
<div
|
||||
className={styles.toggleContainer}
|
||||
role="group"
|
||||
aria-label={i18n._(PURCHASE_MODE_DESCRIPTOR)}
|
||||
data-flx="app.plutonium.pricing-section.toggle-container"
|
||||
>
|
||||
<ToggleButton
|
||||
active={!isGiftMode}
|
||||
onClick={() => setIsGiftMode(false)}
|
||||
label={i18n._(FOR_ME_DESCRIPTOR)}
|
||||
data-flx="app.plutonium.pricing-section.toggle-button.set-is-gift-mode"
|
||||
/>
|
||||
<ToggleButton
|
||||
active={isGiftMode}
|
||||
onClick={() => setIsGiftMode(true)}
|
||||
label={i18n._(AS_A_GIFT_DESCRIPTOR)}
|
||||
data-flx="app.plutonium.pricing-section.toggle-button.set-is-gift-mode--2"
|
||||
/>
|
||||
</div>
|
||||
{giftPurchasesAvailable && (
|
||||
<div
|
||||
className={styles.toggleContainer}
|
||||
role="group"
|
||||
aria-label={i18n._(PURCHASE_MODE_DESCRIPTOR)}
|
||||
data-flx="app.plutonium.pricing-section.toggle-container"
|
||||
>
|
||||
<ToggleButton
|
||||
active={!isGiftMode}
|
||||
onClick={() => setIsGiftMode(false)}
|
||||
label={i18n._(FOR_ME_DESCRIPTOR)}
|
||||
data-flx="app.plutonium.pricing-section.toggle-button.set-is-gift-mode"
|
||||
/>
|
||||
<ToggleButton
|
||||
active={isGiftMode}
|
||||
onClick={() => setIsGiftMode(true)}
|
||||
label={i18n._(AS_A_GIFT_DESCRIPTOR)}
|
||||
data-flx="app.plutonium.pricing-section.toggle-button.set-is-gift-mode--2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={gridStyles.gridWrapper} data-flx="app.plutonium.pricing-section.div">
|
||||
<div className={gridStyles.gridTwoColumns} data-flx="app.plutonium.pricing-section.div--2">
|
||||
{!isGiftMode ? (
|
||||
|
||||
+3
-1
@@ -116,6 +116,7 @@ interface SubscriptionCardProps {
|
||||
handleCommunityButtonClick: () => void;
|
||||
purchaseDisabled?: boolean;
|
||||
purchaseDisabledTooltip?: React.ReactNode;
|
||||
billingUnavailable?: boolean;
|
||||
}
|
||||
|
||||
function getStatusBadgeClass(args: {
|
||||
@@ -180,6 +181,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
handleCommunityButtonClick,
|
||||
purchaseDisabled = false,
|
||||
purchaseDisabledTooltip,
|
||||
billingUnavailable = false,
|
||||
}) => {
|
||||
const {i18n} = useLingui();
|
||||
const {loadingSwitchToListPrice, handleSwitchToListPrice} = useSubscriptionActions();
|
||||
@@ -704,7 +706,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
})()}
|
||||
</div>
|
||||
<div className={styles.actions} data-flx="app.plutonium.subscription-card.actions">
|
||||
{isGiftSubscription ? (
|
||||
{isGiftSubscription || billingUnavailable ? (
|
||||
wrapIfDisabled(
|
||||
<Button
|
||||
variant="primary"
|
||||
|
||||
+20
-7
@@ -11,6 +11,7 @@ import {
|
||||
SUPPORT_EMAIL,
|
||||
UPI_PAYMENT_METHOD,
|
||||
} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import {CANCEL_DESCRIPTOR, CLOSE_DESCRIPTOR, OKAY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {HttpError} from '@app/features/platform/types/EndpointError';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
@@ -140,6 +141,10 @@ const PURCHASES_DISABLED_BODY_DESCRIPTOR = msg({
|
||||
message: 'Purchases are disabled for this account. Contact {supportEmail} if this looks wrong.',
|
||||
comment: 'Modal body shown when purchases are disabled. Provides the support email for appeals.',
|
||||
});
|
||||
const PURCHASES_DISABLED_SELF_HOSTED_BODY_DESCRIPTOR = msg({
|
||||
message: 'Purchases are disabled for this account.',
|
||||
comment: 'Modal body shown on a self-hosted instance when purchases are disabled for the account.',
|
||||
});
|
||||
const CHECKOUT_BLOCKED_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Checkout unavailable',
|
||||
comment: 'Modal title for the generic "checkout blocked" state when no more specific reason is known.',
|
||||
@@ -148,6 +153,10 @@ const CHECKOUT_BLOCKED_BODY_DESCRIPTOR = msg({
|
||||
message: 'Checkout is blocked for this account. Contact {supportEmail} if you need help.',
|
||||
comment: 'Modal body for the generic "checkout blocked" state. Provides the support email.',
|
||||
});
|
||||
const CHECKOUT_BLOCKED_SELF_HOSTED_BODY_DESCRIPTOR = msg({
|
||||
message: 'Checkout is blocked for this account.',
|
||||
comment: 'Modal body for the generic "checkout blocked" state on a self-hosted instance.',
|
||||
});
|
||||
const CHECKOUT_START_FAILED_TITLE_DESCRIPTOR = msg({
|
||||
message: "Couldn't start checkout",
|
||||
comment: 'Title of the generic fallback error modal shown when creating a checkout session fails unexpectedly.',
|
||||
@@ -234,7 +243,7 @@ function alternativePaymentMethodForCurrency(
|
||||
isGift: boolean,
|
||||
plan: Plan,
|
||||
): CheckoutPaymentMethod | null {
|
||||
if (isGift || (plan !== 'monthly' && plan !== 'yearly')) {
|
||||
if (isGift || (plan !== 'monthly' && plan !== 'yearly') || RuntimeConfig.isSelfHosted()) {
|
||||
return null;
|
||||
}
|
||||
if (currency === 'BRL') return 'pix';
|
||||
@@ -404,9 +413,11 @@ export const useCheckoutActions = (
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={i18n._(PURCHASES_DISABLED_TITLE_DESCRIPTOR)}
|
||||
description={i18n._(PURCHASES_DISABLED_BODY_DESCRIPTOR, {
|
||||
supportEmail: SUPPORT_EMAIL,
|
||||
})}
|
||||
description={
|
||||
RuntimeConfig.isSelfHosted()
|
||||
? i18n._(PURCHASES_DISABLED_SELF_HOSTED_BODY_DESCRIPTOR)
|
||||
: i18n._(PURCHASES_DISABLED_BODY_DESCRIPTOR, {supportEmail: SUPPORT_EMAIL})
|
||||
}
|
||||
secondaryText={i18n._(CLOSE_DESCRIPTOR)}
|
||||
data-flx="app.plutonium.use-checkout-actions.handle-checkout-error.confirm-modal--3"
|
||||
/>
|
||||
@@ -418,9 +429,11 @@ export const useCheckoutActions = (
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={i18n._(CHECKOUT_BLOCKED_TITLE_DESCRIPTOR)}
|
||||
description={i18n._(CHECKOUT_BLOCKED_BODY_DESCRIPTOR, {
|
||||
supportEmail: SUPPORT_EMAIL,
|
||||
})}
|
||||
description={
|
||||
RuntimeConfig.isSelfHosted()
|
||||
? i18n._(CHECKOUT_BLOCKED_SELF_HOSTED_BODY_DESCRIPTOR)
|
||||
: i18n._(CHECKOUT_BLOCKED_BODY_DESCRIPTOR, {supportEmail: SUPPORT_EMAIL})
|
||||
}
|
||||
secondaryText={i18n._(CLOSE_DESCRIPTOR)}
|
||||
data-flx="app.plutonium.use-checkout-actions.handle-checkout-error.confirm-modal--4"
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,7 @@ import * as NotificationUtils from '@app/features/notification/utils/Notificatio
|
||||
import NativePermission from '@app/features/permissions/system/state/NativePermission';
|
||||
import {resolvePriceAnnouncementCampaign} from '@app/features/premium/config/PriceAnnouncementCampaign';
|
||||
import PremiumState from '@app/features/premium/state/PremiumState';
|
||||
import {canServiceStripeSubscriptions, shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
|
||||
import StreamerMode from '@app/features/streamer_mode/state/StreamerMode';
|
||||
import Nagbar from '@app/features/ui/state/Nagbar';
|
||||
import {hasUnavailableElectronNativeContext, isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
@@ -81,6 +82,10 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
const premiumWillCancel = user?.premiumWillCancel ?? false;
|
||||
const isMockPremium = premiumOverrideType != null && premiumOverrideType > 0;
|
||||
const isSelfHosted = RuntimeConfig.isSelfHosted();
|
||||
const showPremium = shouldShowPremiumFeatures();
|
||||
const canServiceSubscription =
|
||||
!isSelfHosted ||
|
||||
(canServiceStripeSubscriptions() && (user?.premiumBillingCycle != null || user?.hasEverPurchased === true));
|
||||
const [startupVoiceSessionRestoreSnapshotKey, setStartupVoiceSessionRestoreSnapshotKey] = useState<
|
||||
string | null | undefined
|
||||
>(undefined);
|
||||
@@ -109,7 +114,7 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
return true;
|
||||
})();
|
||||
const canShowPremiumGracePeriod = (() => {
|
||||
if (isSelfHosted) return false;
|
||||
if (!showPremium || !canServiceSubscription) return false;
|
||||
if (nagbarState.forceHidePremiumGracePeriod) return false;
|
||||
if (nagbarState.forcePremiumGracePeriod) return true;
|
||||
if (!user?.premiumUntil || user.premiumType === 2 || premiumWillCancel) return false;
|
||||
@@ -123,7 +128,7 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
return isInGracePeriod && !nagbarState.premiumGracePeriodDismissed;
|
||||
})();
|
||||
const canShowPremiumExpired = (() => {
|
||||
if (isSelfHosted) return false;
|
||||
if (!showPremium || !canServiceSubscription) return false;
|
||||
if (nagbarState.forceHidePremiumExpired) return false;
|
||||
if (nagbarState.forcePremiumExpired) return true;
|
||||
if (!user?.premiumUntil || user.premiumType === 2 || premiumWillCancel) return false;
|
||||
@@ -140,13 +145,13 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
return showExpiredState && !nagbarState.premiumExpiredDismissed;
|
||||
})();
|
||||
const canShowGiftInventory = (() => {
|
||||
if (isSelfHosted) return false;
|
||||
if (!showPremium) return false;
|
||||
if (nagbarState.forceHideGiftInventory) return false;
|
||||
if (nagbarState.forceGiftInventory) return true;
|
||||
return Boolean(user?.hasUnreadGiftInventory && !nagbarState.giftInventoryDismissed);
|
||||
})();
|
||||
const canShowPremiumOnboarding = (() => {
|
||||
if (isSelfHosted) return false;
|
||||
if (!showPremium) return false;
|
||||
if (nagbarState.forceHidePremiumOnboarding) return false;
|
||||
if (nagbarState.forcePremiumOnboarding) return true;
|
||||
if (isMockPremium) return false;
|
||||
|
||||
@@ -339,7 +339,8 @@ const PREMIUM_MIRROR_NAME_DESCRIPTOR = msg({
|
||||
comment: 'Premium model option that mirrors free and premium tiers.',
|
||||
});
|
||||
const PREMIUM_MIRROR_DESC_DESCRIPTOR = msg({
|
||||
message: 'Keep free and premium tiers. You can customize the tiers later.',
|
||||
message:
|
||||
'Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel.',
|
||||
comment: 'Description for the mirror premium model.',
|
||||
});
|
||||
const PREMIUM_EVERYONE_NAME_DESCRIPTOR = msg({
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import {SkeletonLine} from '@app/features/app/components/skeleton/SkeletonLine';
|
||||
import {createSkeletonRandomFromKey} from '@app/features/app/components/skeleton/SkeletonSeed';
|
||||
import {SkeletonEmphasis, SkeletonRadius} from '@app/features/app/components/skeleton/SkeletonStyle';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import {flxElementClassName} from '@app/lib/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
@@ -66,7 +66,7 @@ function createDMActionSkeletonSpecs(
|
||||
if (layout?.personalNotesVisible ?? true) {
|
||||
specs.push({key: 'personal-notes', labelWidth: PERSONAL_NOTES_ACTION_LABEL_WIDTH});
|
||||
}
|
||||
if (layout?.premiumVisible ?? (!RuntimeConfig.isSelfHosted() && !isMobile)) {
|
||||
if (layout?.premiumVisible ?? (shouldShowPremiumFeatures() && !isMobile)) {
|
||||
specs.push({key: 'premium', labelWidth: PREMIUM_ACTION_LABEL_WIDTH});
|
||||
}
|
||||
return Object.freeze(specs);
|
||||
|
||||
@@ -8,6 +8,14 @@ function getBootstrapProductName(): string {
|
||||
return productName || 'Fluxer';
|
||||
}
|
||||
|
||||
function getBootstrapPremiumProductName(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'Plutonium';
|
||||
}
|
||||
const premiumProductName = window.__FLUXER_BOOTSTRAP__?.instance.app_public?.branding?.premium_product_name?.trim();
|
||||
return premiumProductName || 'Plutonium';
|
||||
}
|
||||
|
||||
export const PRODUCT_NAME = getBootstrapProductName();
|
||||
export const PREMIUM_PRODUCT_NAME = 'Plutonium';
|
||||
export const PREMIUM_PRODUCT_NAME = getBootstrapPremiumProductName();
|
||||
export const PREMIUM_PRODUCT_FULL_NAME = `${PRODUCT_NAME} ${PREMIUM_PRODUCT_NAME}`;
|
||||
|
||||
@@ -221,7 +221,13 @@ describe('rewriteImportedAccount', () => {
|
||||
userId: '1',
|
||||
token: 'token',
|
||||
localStorageData: {runtimeConfig: '{}', token: 'token'},
|
||||
managedStorageData: {runtimeConfig: '{}', token: 'token', 'fluxer.theme': 'dark'},
|
||||
managedStorageData: {
|
||||
runtimeConfig: '{}',
|
||||
token: 'token',
|
||||
'fluxer.theme': 'dark',
|
||||
'fluxer.lastPushEndpoint': 'https://push',
|
||||
[core.DOMAIN_MIGRATION_MARKER_KEY]: '{}',
|
||||
},
|
||||
lastActive: NOW,
|
||||
instance: {apiEndpoint: 'https://web.fluxer.app/api'} as RuntimeConfigSnapshot,
|
||||
};
|
||||
@@ -269,7 +275,7 @@ describe('migration gate', () => {
|
||||
['assignment off', {assignmentEnabled: false}],
|
||||
['kill switch', {discovery: {...ENABLED_DISCOVERY, enabled: false}}],
|
||||
['no discovery', {discovery: null}],
|
||||
['already completed', {marker: {state: 'completed', target: 'https://fluxer.com', at: NOW}}],
|
||||
['already completed', {marker: {state: 'completed', target: 'https://fluxer.com', at: NOW, attempts: 1}}],
|
||||
['failed recently', {marker: {state: 'failed', at: NOW - 60_000, attempts: 1}}],
|
||||
['failed too often', {marker: {state: 'failed', at: NOW - 3 * 24 * 60 * 60 * 1000, attempts: 3}}],
|
||||
['Android web app', {environment: environment('chromium-android')}],
|
||||
@@ -315,9 +321,10 @@ describe('marker state', () => {
|
||||
state: 'completed',
|
||||
target: 'https://fluxer.com',
|
||||
at: NOW + 2,
|
||||
attempts: 2,
|
||||
});
|
||||
core.markDomainMigrationFailed(storage, NOW + 3);
|
||||
expect(core.readDomainMigrationMarker(storage)).toEqual({state: 'failed', at: NOW + 3, attempts: 1});
|
||||
expect(core.readDomainMigrationMarker(storage)).toEqual({state: 'failed', at: NOW + 3, attempts: 2});
|
||||
});
|
||||
|
||||
it('ignores malformed markers', () => {
|
||||
@@ -352,7 +359,12 @@ describe('forwarding', () => {
|
||||
});
|
||||
|
||||
it('honours the kill switch', () => {
|
||||
const completed: core.DomainMigrationMarker = {state: 'completed', target: 'https://fluxer.com', at: NOW};
|
||||
const completed: core.DomainMigrationMarker = {
|
||||
state: 'completed',
|
||||
target: 'https://fluxer.com',
|
||||
at: NOW,
|
||||
attempts: 1,
|
||||
};
|
||||
const browser = environment('none');
|
||||
expect(core.shouldForwardCompletedSource(ENABLED_DISCOVERY, completed, browser)).toBe(true);
|
||||
expect(core.shouldForwardCompletedSource({...ENABLED_DISCOVERY, enabled: false}, completed, browser)).toBe(false);
|
||||
@@ -361,7 +373,12 @@ describe('forwarding', () => {
|
||||
});
|
||||
|
||||
it('forwards installed apps only when standalone forwarding is on', () => {
|
||||
const completed: core.DomainMigrationMarker = {state: 'completed', target: 'https://fluxer.com', at: NOW};
|
||||
const completed: core.DomainMigrationMarker = {
|
||||
state: 'completed',
|
||||
target: 'https://fluxer.com',
|
||||
at: NOW,
|
||||
attempts: 1,
|
||||
};
|
||||
const forwarding = {...ENABLED_DISCOVERY, standalone_forwarding: true};
|
||||
const desktop = environment('chromium-desktop');
|
||||
expect(core.shouldForwardCompletedSource(ENABLED_DISCOVERY, completed, desktop)).toBe(false);
|
||||
@@ -488,7 +505,12 @@ describe('classifyDomainMigrationInstallKind', () => {
|
||||
|
||||
describe('shouldShowDomainMovedNotice', () => {
|
||||
const source = core.resolveDomainMigrationSide('https://web.fluxer.app');
|
||||
const completed: core.DomainMigrationMarker = {state: 'completed', target: 'https://fluxer.com', at: NOW};
|
||||
const completed: core.DomainMigrationMarker = {
|
||||
state: 'completed',
|
||||
target: 'https://fluxer.com',
|
||||
at: NOW,
|
||||
attempts: 1,
|
||||
};
|
||||
|
||||
function notice(overrides: Partial<core.DomainMovedNoticeInput>): core.DomainMovedNoticeInput {
|
||||
return {
|
||||
@@ -632,10 +654,17 @@ describe('runDomainMigrationPreMount', () => {
|
||||
});
|
||||
|
||||
it('forwards a migrated source tab with its hash', async () => {
|
||||
visit('https://web.fluxer.app/reset#token=abc', ENABLED_DISCOVERY);
|
||||
visit('https://web.fluxer.app/channels/1/2?a=1#b', ENABLED_DISCOVERY);
|
||||
writeCompletedMarker();
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith('https://fluxer.com/reset#token=abc');
|
||||
expect(replace).toHaveBeenCalledWith('https://fluxer.com/channels/1/2?a=1#b');
|
||||
});
|
||||
|
||||
it('keeps one-shot routes on the source', async () => {
|
||||
visit('https://web.fluxer.app/reset#token=abc', ENABLED_DISCOVERY);
|
||||
writeCompletedMarker();
|
||||
expect(await runDomainMigrationPreMount()).toBe(false);
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stays put when the kill switch is off', async () => {
|
||||
@@ -703,10 +732,10 @@ describe('runDomainMigrationPreMount', () => {
|
||||
it('resumes through the target when a migrated source still holds a session', async () => {
|
||||
writeCompletedMarker();
|
||||
window.localStorage.setItem('token', 'session-token');
|
||||
visit('https://web.fluxer.app/reset#token=abc', ENABLED_DISCOVERY);
|
||||
visit('https://web.fluxer.app/channels/1/2#x', ENABLED_DISCOVERY);
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith(
|
||||
`https://fluxer.com/migrate/begin?resume=1&next=${encodeURIComponent('/reset#token=abc')}`,
|
||||
`https://fluxer.com/migrate/begin?resume=1&imported=1&next=${encodeURIComponent('/channels/1/2#x')}`,
|
||||
);
|
||||
expect(core.readDomainMigrationIntent(window.sessionStorage, Date.now())).not.toBeNull();
|
||||
});
|
||||
@@ -795,11 +824,14 @@ describe('runDomainMigrationPreMount', () => {
|
||||
expect(replace.mock.calls[0]?.[0]).toMatch(/^https:\/\/web\.fluxer\.app\/migrate\/export\?n=[\w-]+$/u);
|
||||
});
|
||||
|
||||
it('opens the target directly when the browser already migrated', async () => {
|
||||
it('opens the target in place when the browser already migrated', async () => {
|
||||
window.localStorage.setItem('token', 'session-token');
|
||||
visit('https://fluxer.com/migrate/begin?start=1&next=%2Fapp', ENABLED_DISCOVERY);
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith('https://fluxer.com/app');
|
||||
const replaceState = vi.fn();
|
||||
vi.stubGlobal('history', {state: null, replaceState});
|
||||
expect(await runDomainMigrationPreMount()).toBe(false);
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
expect(replaceState).toHaveBeenCalledWith(null, '', '/app');
|
||||
});
|
||||
|
||||
it('does not start a migration inside an installed Android app', async () => {
|
||||
@@ -819,14 +851,24 @@ describe('runDomainMigrationPreMount', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reports back to the source when the target already holds a session', async () => {
|
||||
it('still imports into a target that already holds a session', async () => {
|
||||
window.localStorage.setItem('token', 'session-token');
|
||||
visit('https://fluxer.com/migrate/begin?next=%2Fchannels%2F1', ENABLED_DISCOVERY);
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/migrate/done?next=%2Fchannels%2F1');
|
||||
expect(replace.mock.calls[0]?.[0]).toMatch(/^https:\/\/web\.fluxer\.app\/migrate\/export\?n=[\w-]+$/u);
|
||||
expect(window.sessionStorage.getItem(core.DOMAIN_MIGRATION_PENDING_KEY)).not.toBeNull();
|
||||
|
||||
window.localStorage.removeItem(core.DOMAIN_MIGRATION_IMPORT_KEY);
|
||||
visit('https://fluxer.com/migrate/begin?resume=1&next=%2Fchannels%2F1', ENABLED_DISCOVERY);
|
||||
expect(await runDomainMigrationPreMount()).toBe(true);
|
||||
expect(replace).toHaveBeenCalledWith('https://fluxer.com/channels/1');
|
||||
expect(replace.mock.calls[0]?.[0]).toMatch(/^https:\/\/web\.fluxer\.app\/migrate\/export\?n=[\w-]+$/u);
|
||||
|
||||
core.writeDomainMigrationImport(window.localStorage, {state: 'done', at: Date.now()});
|
||||
visit('https://fluxer.com/migrate/begin?resume=1&next=%2Fchannels%2F1', ENABLED_DISCOVERY);
|
||||
const replaceState = vi.fn();
|
||||
vi.stubGlobal('history', {state: null, replaceState});
|
||||
expect(await runDomainMigrationPreMount()).toBe(false);
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
expect(replaceState).toHaveBeenCalledWith(null, '', '/channels/1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,17 +4,23 @@ import {
|
||||
classifyDomainMigrationInstallKind,
|
||||
type DomainMigrationDisplayMode,
|
||||
type DomainMigrationEnvironment,
|
||||
type DomainMigrationGateInput,
|
||||
type DomainMigrationInstallKind,
|
||||
type DomainMigrationSide,
|
||||
domainMovedBrowserMigrationUrl,
|
||||
domainMovedInstallUrl,
|
||||
domainMovedManifestId,
|
||||
isDomainMigrationOneShotRoute,
|
||||
markDomainMigrationFailed,
|
||||
readDomainMigrationMarker,
|
||||
writeDomainMigrationIntent,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationCore';
|
||||
import {
|
||||
AuthSessionStorageKey,
|
||||
parseStoredSessionValue,
|
||||
} from '@app/features/platform/state/auth_session/AuthSessionStorage';
|
||||
import {getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage';
|
||||
import {hasUnavailableElectronNativeContext, isElectron} from '@app/features/ui/utils/NativeUtils';
|
||||
import {getProtectedLocalStorage, getProtectedSessionStorage} from '@app/features/platform/state/ProtectedWebStorage';
|
||||
import {hasUnavailableElectronNativeContext, isElectron} from '@app/features/ui/utils/ElectronRuntime';
|
||||
import type {DomainMigrationDiscoveryResponse} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
|
||||
interface NavigatorWithStandalone extends Navigator {
|
||||
@@ -68,6 +74,29 @@ export function readDomainMigrationEnvironment(): DomainMigrationEnvironment {
|
||||
};
|
||||
}
|
||||
|
||||
export function readDomainMigrationGateInput(
|
||||
assignmentEnabled: boolean,
|
||||
voiceActive: boolean,
|
||||
): DomainMigrationGateInput {
|
||||
return {
|
||||
environment: readDomainMigrationEnvironment(),
|
||||
assignmentEnabled,
|
||||
discovery: readDomainMigrationDiscovery(),
|
||||
marker: readDomainMigrationMarker(getProtectedLocalStorage()),
|
||||
now: Date.now(),
|
||||
voiceActive,
|
||||
oneShotRoute: isDomainMigrationOneShotRoute(window.location.pathname),
|
||||
};
|
||||
}
|
||||
|
||||
export function startDomainMigrationFromSource(side: DomainMigrationSide): true {
|
||||
markDomainMigrationFailed(getProtectedLocalStorage(), Date.now());
|
||||
writeDomainMigrationIntent(getProtectedSessionStorage(), {at: Date.now()});
|
||||
const next = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
window.location.replace(`${side.target}/migrate/begin?next=${encodeURIComponent(next)}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function desktopPasskeysSupported(): Promise<boolean> {
|
||||
if (!isElectronEnvironment()) {
|
||||
return true;
|
||||
|
||||
@@ -22,6 +22,11 @@ export const DOMAIN_MIGRATION_PENDING_KEY = 'fluxer:domain-migration:pending';
|
||||
export const DOMAIN_MIGRATION_INTENT_KEY = 'fluxer:domain-migration:intent';
|
||||
export const DOMAIN_MIGRATION_NOTIFICATIONS_KEY = 'fluxer:domain-migration:notifications';
|
||||
export const DOMAIN_MIGRATION_MOVED_DISMISSED_KEY = 'fluxer:domain-migration:moved-dismissed-at';
|
||||
export const DOMAIN_MIGRATION_ENROLLED_KEY = 'fluxer:domain-migration:enrolled';
|
||||
export const DOMAIN_MIGRATION_PROBED_AT_KEY = 'fluxer:domain-migration:probed-at';
|
||||
export const DOMAIN_MIGRATION_IMPORT_KEY = 'fluxer:domain-migration:import';
|
||||
export const DOMAIN_MIGRATION_DEVICES_KEY = 'fluxer:domain-migration:devices';
|
||||
export const DOMAIN_MIGRATION_STORAGE_KEY_PREFIXES: ReadonlyArray<string> = [DOMAIN_MIGRATION_MARKER_KEY];
|
||||
|
||||
export const DOMAIN_MIGRATION_PAYLOAD_VERSION = 1;
|
||||
export const DOMAIN_MIGRATION_DEFAULT_NEXT_PATH = '/channels/@me';
|
||||
@@ -31,6 +36,21 @@ export const DOMAIN_MIGRATION_PENDING_MAX_AGE_MS = 10 * 60 * 1000;
|
||||
export const DOMAIN_MIGRATION_CUSTOM_SOUNDS_MAX_BYTES = 4 * 1024 * 1024;
|
||||
export const DOMAIN_MIGRATION_THEME_ASSETS_MAX_BYTES = 2 * 1024 * 1024;
|
||||
export const DOMAIN_MIGRATION_MOVED_DISMISS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
export const DOMAIN_MIGRATION_PROBE_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
export const DOMAIN_MIGRATION_IMPORT_STALE_MS = 2 * 60 * 1000;
|
||||
export const DOMAIN_MIGRATION_DEVICES_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const ONE_SHOT_ROUTE_PREFIXES: ReadonlyArray<string> = [
|
||||
'/reset',
|
||||
'/verify',
|
||||
'/authorize-ip',
|
||||
'/wasntme',
|
||||
'/oauth2/authorize',
|
||||
'/auth/sso/callback',
|
||||
'/premium-callback',
|
||||
'/age-verification-callback',
|
||||
'/connection-callback',
|
||||
];
|
||||
|
||||
const NEXT_PATH_BASE = 'https://next.invalid';
|
||||
|
||||
@@ -92,12 +112,20 @@ export function sanitizeNextPath(value: unknown): string {
|
||||
return `${pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
export function isDomainMigrationStorageKey(key: string): boolean {
|
||||
return key.startsWith(DOMAIN_MIGRATION_MARKER_KEY);
|
||||
}
|
||||
|
||||
export function isDomainMigrationOneShotRoute(pathname: string): boolean {
|
||||
return ONE_SHOT_ROUTE_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
|
||||
}
|
||||
|
||||
export function buildTargetUrl(target: string, pathname: string, search: string, hash: string): string {
|
||||
return `${target}${sanitizeNextPath(`${pathname}${search}${hash}`)}`;
|
||||
}
|
||||
|
||||
export type DomainMigrationMarker =
|
||||
| {state: 'completed'; target: string; at: number}
|
||||
| {state: 'completed' | 'handed-off'; target: string; at: number; attempts: number}
|
||||
| {state: 'failed'; at: number; attempts: number};
|
||||
|
||||
export function parseDomainMigrationMarker(raw: string | null): DomainMigrationMarker | null {
|
||||
@@ -109,8 +137,9 @@ export function parseDomainMigrationMarker(raw: string | null): DomainMigrationM
|
||||
if (typeof value !== 'object' || value === null || typeof value.at !== 'number') {
|
||||
return null;
|
||||
}
|
||||
if (value.state === 'completed' && typeof value.target === 'string') {
|
||||
return {state: 'completed', target: value.target, at: value.at};
|
||||
if ((value.state === 'completed' || value.state === 'handed-off') && typeof value.target === 'string') {
|
||||
const attempts = typeof value.attempts === 'number' && value.attempts > 0 ? value.attempts : 0;
|
||||
return {state: value.state, target: value.target, at: value.at, attempts};
|
||||
}
|
||||
if (value.state === 'failed') {
|
||||
const attempts = typeof value.attempts === 'number' && value.attempts > 0 ? value.attempts : 1;
|
||||
@@ -137,12 +166,26 @@ function writeDomainMigrationMarker(storage: StorageLike | null, marker: DomainM
|
||||
}
|
||||
|
||||
export function markDomainMigrationCompleted(storage: StorageLike | null, target: string, now: number): void {
|
||||
writeDomainMigrationMarker(storage, {state: 'completed', target, at: now});
|
||||
const attempts = readDomainMigrationMarker(storage)?.attempts ?? 0;
|
||||
writeDomainMigrationMarker(storage, {state: 'completed', target, at: now, attempts});
|
||||
}
|
||||
|
||||
export function markDomainMigrationHandedOff(
|
||||
storage: StorageLike | null,
|
||||
target: string,
|
||||
now: number,
|
||||
attempts: number,
|
||||
): void {
|
||||
writeDomainMigrationMarker(storage, {state: 'handed-off', target, at: now, attempts});
|
||||
}
|
||||
|
||||
export function isCompletedDomainMigrationMarker(marker: DomainMigrationMarker | null): boolean {
|
||||
return marker !== null && marker.state !== 'failed';
|
||||
}
|
||||
|
||||
export function markDomainMigrationFailed(storage: StorageLike | null, now: number): void {
|
||||
const previous = readDomainMigrationMarker(storage);
|
||||
const attempts = previous?.state === 'failed' ? previous.attempts + 1 : 1;
|
||||
const attempts = previous?.state === 'failed' ? previous.attempts + 1 : Math.max(previous?.attempts ?? 0, 1);
|
||||
writeDomainMigrationMarker(storage, {state: 'failed', at: now, attempts});
|
||||
}
|
||||
|
||||
@@ -150,7 +193,7 @@ export function markerAllowsDomainMigration(marker: DomainMigrationMarker | null
|
||||
if (marker === null) {
|
||||
return true;
|
||||
}
|
||||
if (marker.state === 'completed') {
|
||||
if (marker.state !== 'failed') {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
@@ -158,6 +201,150 @@ export function markerAllowsDomainMigration(marker: DomainMigrationMarker | null
|
||||
);
|
||||
}
|
||||
|
||||
function readTimestampRecord(storage: StorageLike | null, key: string): number | null {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(key) ?? 'null') as unknown;
|
||||
return isRecord(value) && typeof value.at === 'number' ? value.at : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeTimestampRecord(storage: StorageLike | null, key: string, now: number): void {
|
||||
try {
|
||||
storage?.setItem(key, JSON.stringify({at: now}));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function readDomainMigrationEnrollment(storage: StorageLike | null): boolean {
|
||||
return readTimestampRecord(storage, DOMAIN_MIGRATION_ENROLLED_KEY) !== null;
|
||||
}
|
||||
|
||||
export function markDomainMigrationEnrolled(storage: StorageLike | null, now: number): void {
|
||||
if (!readDomainMigrationEnrollment(storage)) {
|
||||
writeTimestampRecord(storage, DOMAIN_MIGRATION_ENROLLED_KEY, now);
|
||||
}
|
||||
}
|
||||
|
||||
export function domainMigrationProbeIsDue(storage: StorageLike | null, now: number): boolean {
|
||||
const probedAt = readTimestampRecord(storage, DOMAIN_MIGRATION_PROBED_AT_KEY);
|
||||
return probedAt === null || now - probedAt >= DOMAIN_MIGRATION_PROBE_INTERVAL_MS || probedAt > now;
|
||||
}
|
||||
|
||||
export function markDomainMigrationProbed(storage: StorageLike | null, now: number): void {
|
||||
writeTimestampRecord(storage, DOMAIN_MIGRATION_PROBED_AT_KEY, now);
|
||||
}
|
||||
|
||||
export interface DomainMigrationImportRecord {
|
||||
state: 'running' | 'done';
|
||||
at: number;
|
||||
}
|
||||
|
||||
export function readDomainMigrationImport(storage: StorageLike | null): DomainMigrationImportRecord | null {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(DOMAIN_MIGRATION_IMPORT_KEY) ?? 'null') as unknown;
|
||||
if (!isRecord(value) || typeof value.at !== 'number' || (value.state !== 'running' && value.state !== 'done')) {
|
||||
return null;
|
||||
}
|
||||
return {state: value.state, at: value.at};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeDomainMigrationImport(
|
||||
storage: StorageLike | null,
|
||||
record: DomainMigrationImportRecord | null,
|
||||
): void {
|
||||
try {
|
||||
if (record === null) {
|
||||
storage?.removeItem(DOMAIN_MIGRATION_IMPORT_KEY);
|
||||
} else {
|
||||
storage?.setItem(DOMAIN_MIGRATION_IMPORT_KEY, JSON.stringify(record));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function importIsRunning(record: DomainMigrationImportRecord | null, now: number): boolean {
|
||||
return record?.state === 'running' && now - record.at < DOMAIN_MIGRATION_IMPORT_STALE_MS;
|
||||
}
|
||||
|
||||
export type DomainMigrationMediaDeviceKind = 'audioinput' | 'audiooutput' | 'videoinput';
|
||||
|
||||
export interface DomainMigrationMediaDevice {
|
||||
kind: DomainMigrationMediaDeviceKind;
|
||||
device_id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface DomainMigrationDeviceMap {
|
||||
at: number;
|
||||
devices: Array<DomainMigrationMediaDevice>;
|
||||
resolved: Array<DomainMigrationMediaDeviceKind>;
|
||||
}
|
||||
|
||||
function isMediaDeviceKind(value: unknown): value is DomainMigrationMediaDeviceKind {
|
||||
return value === 'audioinput' || value === 'audiooutput' || value === 'videoinput';
|
||||
}
|
||||
|
||||
function isMediaDevice(value: unknown): value is DomainMigrationMediaDevice {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
isMediaDeviceKind(value.kind) &&
|
||||
typeof value.device_id === 'string' &&
|
||||
value.device_id.length > 0 &&
|
||||
typeof value.label === 'string' &&
|
||||
value.label.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function readDomainMigrationDeviceMap(
|
||||
storage: StorageLike | null,
|
||||
now: number,
|
||||
): DomainMigrationDeviceMap | null {
|
||||
let raw: string | null;
|
||||
try {
|
||||
raw = storage?.getItem(DOMAIN_MIGRATION_DEVICES_KEY) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
} catch {
|
||||
value = null;
|
||||
}
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value.at !== 'number' ||
|
||||
now - value.at > DOMAIN_MIGRATION_DEVICES_MAX_AGE_MS ||
|
||||
value.at > now ||
|
||||
!Array.isArray(value.devices) ||
|
||||
!Array.isArray(value.resolved)
|
||||
) {
|
||||
writeDomainMigrationDeviceMap(storage, null);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
at: value.at,
|
||||
devices: value.devices.filter(isMediaDevice),
|
||||
resolved: value.resolved.filter(isMediaDeviceKind),
|
||||
};
|
||||
}
|
||||
|
||||
export function writeDomainMigrationDeviceMap(storage: StorageLike | null, map: DomainMigrationDeviceMap | null): void {
|
||||
try {
|
||||
if (map === null) {
|
||||
storage?.removeItem(DOMAIN_MIGRATION_DEVICES_KEY);
|
||||
} else {
|
||||
storage?.setItem(DOMAIN_MIGRATION_DEVICES_KEY, JSON.stringify(map));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export interface DomainMigrationIntent {
|
||||
at: number;
|
||||
handoff_id?: string;
|
||||
@@ -310,7 +497,11 @@ export function shouldForwardCompletedSource(
|
||||
marker: DomainMigrationMarker | null,
|
||||
environment: DomainMigrationEnvironment,
|
||||
): boolean {
|
||||
return discovery?.enabled === true && marker?.state === 'completed' && environmentMayForward(environment, discovery);
|
||||
return (
|
||||
discovery?.enabled === true &&
|
||||
isCompletedDomainMigrationMarker(marker) &&
|
||||
environmentMayForward(environment, discovery)
|
||||
);
|
||||
}
|
||||
|
||||
export interface DomainMovedNoticeInput {
|
||||
@@ -330,7 +521,7 @@ export function shouldShowDomainMovedNotice(input: DomainMovedNoticeInput): bool
|
||||
if (input.dismissedAt !== null && input.now - input.dismissedAt < DOMAIN_MIGRATION_MOVED_DISMISS_MS) {
|
||||
return false;
|
||||
}
|
||||
const completed = input.marker?.state === 'completed';
|
||||
const completed = isCompletedDomainMigrationMarker(input.marker);
|
||||
if (input.installKind === 'chromium-desktop') {
|
||||
return completed;
|
||||
}
|
||||
@@ -359,9 +550,7 @@ export function deviceIsInAnonymousRollout(discovery: DomainMigrationDiscoveryRe
|
||||
|
||||
export function isExportableLocalStorageKey(key: string): boolean {
|
||||
return (
|
||||
!DENIED_LOCAL_STORAGE_KEYS.has(key) &&
|
||||
!key.startsWith(DOMAIN_MIGRATION_MARKER_KEY) &&
|
||||
!PUSH_SUBSCRIPTION_KEY_PATTERN.test(key)
|
||||
!DENIED_LOCAL_STORAGE_KEYS.has(key) && !isDomainMigrationStorageKey(key) && !PUSH_SUBSCRIPTION_KEY_PATTERN.test(key)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -416,6 +605,7 @@ export interface DomainMigrationPayload {
|
||||
accounts: Array<StoredAccount>;
|
||||
custom_sounds?: Array<DomainMigrationCustomSound>;
|
||||
theme_library?: DomainMigrationThemeLibrary;
|
||||
media_devices?: Array<DomainMigrationMediaDevice>;
|
||||
notification_permission: string;
|
||||
}
|
||||
|
||||
@@ -508,16 +698,31 @@ export function parseDomainMigrationPayload(value: unknown, expectedSource: stri
|
||||
if (value.theme_library !== undefined && !isThemeLibrary(value.theme_library)) {
|
||||
return null;
|
||||
}
|
||||
return value as unknown as DomainMigrationPayload;
|
||||
const mediaDevices = Array.isArray(value.media_devices) ? value.media_devices.filter(isMediaDevice) : [];
|
||||
return {
|
||||
...(value as unknown as DomainMigrationPayload),
|
||||
media_devices: mediaDevices.length > 0 ? mediaDevices : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function withoutRuntimeConfig(snapshot: Record<string, string> | undefined): Record<string, string> {
|
||||
const {runtimeConfig: _runtimeConfig, ...rest} = snapshot ?? {};
|
||||
return rest;
|
||||
function exportableSnapshot(snapshot: Record<string, string> | undefined): Record<string, string> {
|
||||
return Object.fromEntries(Object.entries(snapshot ?? {}).filter(([key]) => isExportableLocalStorageKey(key)));
|
||||
}
|
||||
|
||||
export function keepValidTargetSession(incoming: StoredAccount, existing: StoredAccount | undefined): StoredAccount {
|
||||
if (incoming.isValid !== false || !existing?.token || existing.isValid === false) {
|
||||
return incoming;
|
||||
}
|
||||
return {
|
||||
...incoming,
|
||||
token: existing.token,
|
||||
isValid: existing.isValid,
|
||||
userData: existing.userData ?? incoming.userData,
|
||||
};
|
||||
}
|
||||
|
||||
export function rewriteImportedAccount(record: StoredAccount, instance: RuntimeConfigSnapshot): StoredAccount {
|
||||
const managed = withoutRuntimeConfig(record.managedStorageData ?? record.localStorageData);
|
||||
const managed = exportableSnapshot(record.managedStorageData ?? record.localStorageData);
|
||||
return {
|
||||
...record,
|
||||
localStorageData: managed,
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
type DomainMigrationDeviceMap,
|
||||
type DomainMigrationMediaDevice,
|
||||
type DomainMigrationMediaDeviceKind,
|
||||
readDomainMigrationDeviceMap,
|
||||
writeDomainMigrationDeviceMap,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationCore';
|
||||
import {getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage';
|
||||
import {awaitHydration} from '@app/features/platform/utils/MobXPersistence';
|
||||
import VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState';
|
||||
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||
import {
|
||||
getVoiceAudioDeviceMetadata,
|
||||
normalizeDeviceMatchLabel,
|
||||
type VoiceDeviceState,
|
||||
} from '@app/features/voice/utils/VoiceDeviceManager';
|
||||
|
||||
const remappedDeviceIds = new Map<DomainMigrationMediaDeviceKind, Map<string, string>>();
|
||||
let installed = false;
|
||||
let pendingDeviceMap: DomainMigrationDeviceMap | null = null;
|
||||
|
||||
function devicesOfKind(state: VoiceDeviceState, kind: DomainMigrationMediaDeviceKind): Array<MediaDeviceInfo> {
|
||||
const devices =
|
||||
kind === 'audioinput' ? state.inputDevices : kind === 'audiooutput' ? state.outputDevices : state.videoDevices;
|
||||
return devices.filter(
|
||||
(device) =>
|
||||
device.deviceId.trim().length > 0 &&
|
||||
device.deviceId !== 'default' &&
|
||||
device.deviceId !== 'communications' &&
|
||||
getVoiceAudioDeviceMetadata(device) === null,
|
||||
);
|
||||
}
|
||||
|
||||
function hasLabelledDevices(state: VoiceDeviceState, kind: DomainMigrationMediaDeviceKind): boolean {
|
||||
const devices = devicesOfKind(state, kind);
|
||||
const permission = kind === 'videoinput' ? state.permissionStatus.video : state.permissionStatus.audio;
|
||||
return devices.some((device) => device.label.trim().length > 0) || (permission === 'granted' && devices.length > 0);
|
||||
}
|
||||
|
||||
function groupByLabel<T>(
|
||||
entries: ReadonlyArray<T>,
|
||||
labelOf: (entry: T) => string,
|
||||
idOf: (entry: T) => string,
|
||||
): Map<string, Array<string>> {
|
||||
const groups = new Map<string, Array<string>>();
|
||||
for (const entry of entries) {
|
||||
const label = labelOf(entry);
|
||||
if (label.length === 0) {
|
||||
continue;
|
||||
}
|
||||
groups.set(label, [...(groups.get(label) ?? []), idOf(entry)]);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function mapDevicesOfKind(
|
||||
kind: DomainMigrationMediaDeviceKind,
|
||||
sourceDevices: ReadonlyArray<DomainMigrationMediaDevice>,
|
||||
targetDevices: ReadonlyArray<MediaDeviceInfo>,
|
||||
): Map<string, string> {
|
||||
const sourceByLabel = groupByLabel(
|
||||
sourceDevices.filter((device) => device.kind === kind),
|
||||
(device) => normalizeDeviceMatchLabel(kind, device.device_id, device.label),
|
||||
(device) => device.device_id,
|
||||
);
|
||||
const targetByLabel = groupByLabel(
|
||||
targetDevices,
|
||||
(device) => normalizeDeviceMatchLabel(kind, device.deviceId, device.label),
|
||||
(device) => device.deviceId,
|
||||
);
|
||||
const mapping = new Map<string, string>();
|
||||
for (const [label, sourceIds] of sourceByLabel) {
|
||||
const targetIds = targetByLabel.get(label);
|
||||
if (sourceIds.length === 1 && targetIds?.length === 1 && sourceIds[0] !== targetIds[0]) {
|
||||
mapping.set(sourceIds[0], targetIds[0]);
|
||||
}
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
export function remapMigratedDeviceId(kind: DomainMigrationMediaDeviceKind, deviceId: string): string {
|
||||
return remappedDeviceIds.get(kind)?.get(deviceId) ?? deviceId;
|
||||
}
|
||||
|
||||
export function remapMigratedDeviceIds(
|
||||
deviceIds: Array<string>,
|
||||
kinds: ReadonlyArray<DomainMigrationMediaDeviceKind>,
|
||||
): Array<string> {
|
||||
let changed = false;
|
||||
const remapped = deviceIds.flatMap((deviceId) => {
|
||||
const targets = [...new Set(kinds.map((kind) => remapMigratedDeviceId(kind, deviceId)))].filter(
|
||||
(target) => target !== deviceId,
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
return [deviceId];
|
||||
}
|
||||
changed = true;
|
||||
return targets;
|
||||
});
|
||||
return changed ? [...new Set(remapped)] : deviceIds;
|
||||
}
|
||||
|
||||
export function isPendingMigratedDeviceId(deviceId: string): boolean {
|
||||
const map = pendingDeviceMap;
|
||||
return map?.devices.some((device) => device.device_id === deviceId && !map.resolved.includes(device.kind)) ?? false;
|
||||
}
|
||||
|
||||
function applyToVoiceSettings(): void {
|
||||
const current = {
|
||||
inputDeviceId: VoiceSettings.getInputDeviceId(),
|
||||
outputDeviceId: VoiceSettings.getOutputDeviceId(),
|
||||
videoDeviceId: VoiceSettings.getVideoDeviceId(),
|
||||
screenShareAudioDeviceId: VoiceSettings.getScreenShareAudioDeviceId(),
|
||||
};
|
||||
const next = {
|
||||
inputDeviceId: remapMigratedDeviceId('audioinput', current.inputDeviceId),
|
||||
outputDeviceId: remapMigratedDeviceId('audiooutput', current.outputDeviceId),
|
||||
videoDeviceId: remapMigratedDeviceId('videoinput', current.videoDeviceId),
|
||||
screenShareAudioDeviceId: remapMigratedDeviceId('audioinput', current.screenShareAudioDeviceId),
|
||||
};
|
||||
if (
|
||||
next.inputDeviceId !== current.inputDeviceId ||
|
||||
next.outputDeviceId !== current.outputDeviceId ||
|
||||
next.videoDeviceId !== current.videoDeviceId ||
|
||||
next.screenShareAudioDeviceId !== current.screenShareAudioDeviceId
|
||||
) {
|
||||
VoiceSettings.updateSettings(next);
|
||||
}
|
||||
}
|
||||
|
||||
function reconcileMigratedDevices(state: VoiceDeviceState): void {
|
||||
const map = pendingDeviceMap;
|
||||
if (map === null) {
|
||||
return;
|
||||
}
|
||||
const pendingKinds = [...new Set(map.devices.map((device) => device.kind))].filter(
|
||||
(kind) => !map.resolved.includes(kind),
|
||||
);
|
||||
const resolvableKinds = pendingKinds.filter((kind) => hasLabelledDevices(state, kind));
|
||||
if (pendingKinds.length > 0 && resolvableKinds.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const kind of resolvableKinds) {
|
||||
const mapping = mapDevicesOfKind(kind, map.devices, devicesOfKind(state, kind));
|
||||
remappedDeviceIds.set(kind, new Map([...(remappedDeviceIds.get(kind) ?? []), ...mapping]));
|
||||
}
|
||||
applyToVoiceSettings();
|
||||
const resolved = [...map.resolved, ...resolvableKinds];
|
||||
const allResolved = pendingKinds.every((kind) => resolved.includes(kind));
|
||||
pendingDeviceMap = allResolved ? null : {...map, resolved};
|
||||
const storage = getProtectedLocalStorage();
|
||||
const stored = readDomainMigrationDeviceMap(storage, Date.now());
|
||||
if (stored === null) {
|
||||
return;
|
||||
}
|
||||
const storedResolved = [...new Set([...stored.resolved, ...resolved])];
|
||||
const storedKinds = new Set(stored.devices.map((device) => device.kind));
|
||||
writeDomainMigrationDeviceMap(
|
||||
storage,
|
||||
[...storedKinds].every((kind) => storedResolved.includes(kind)) ? null : {...stored, resolved: storedResolved},
|
||||
);
|
||||
}
|
||||
|
||||
export async function installMigratedDeviceRemap(): Promise<void> {
|
||||
if (installed) {
|
||||
return;
|
||||
}
|
||||
const map = readDomainMigrationDeviceMap(getProtectedLocalStorage(), Date.now());
|
||||
if (map === null) {
|
||||
return;
|
||||
}
|
||||
installed = true;
|
||||
pendingDeviceMap = {...map, resolved: []};
|
||||
await awaitHydration('VoiceSettings');
|
||||
VoiceDevicePermissionState.addDeviceStateReconciler(reconcileMigratedDevices);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
desktopPasskeysSupported,
|
||||
hasStoredAccount,
|
||||
readActiveSessionToken,
|
||||
readDomainMigrationDiscovery,
|
||||
readDomainMigrationEnvironment,
|
||||
readDomainMigrationGateInput,
|
||||
startDomainMigrationFromSource,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationBrowser';
|
||||
import {
|
||||
anonymousRolloutIsOpen,
|
||||
@@ -15,24 +18,33 @@ import {
|
||||
DOMAIN_MIGRATION_DEFAULT_NEXT_PATH,
|
||||
DOMAIN_MIGRATION_DEVICE_KEY,
|
||||
DOMAIN_MIGRATION_MARKER_KEY,
|
||||
DOMAIN_MIGRATION_MAX_FAILED_ATTEMPTS,
|
||||
DOMAIN_MIGRATION_NOTIFICATIONS_KEY,
|
||||
DOMAIN_MIGRATION_PAYLOAD_VERSION,
|
||||
DOMAIN_MIGRATION_PENDING_KEY,
|
||||
DOMAIN_MIGRATION_PENDING_MAX_AGE_MS,
|
||||
DOMAIN_MIGRATION_THEME_ASSETS_MAX_BYTES,
|
||||
type DomainMigrationCustomSound,
|
||||
type DomainMigrationMediaDevice,
|
||||
type DomainMigrationPayload,
|
||||
type DomainMigrationSide,
|
||||
type DomainMigrationThemeLibrary,
|
||||
deviceIsInAnonymousRollout,
|
||||
environmentAllowsDomainMigration,
|
||||
environmentMayForward,
|
||||
importIsRunning,
|
||||
intentConfirmsCompletion,
|
||||
isCompletedDomainMigrationMarker,
|
||||
isDomainMigrationOneShotRoute,
|
||||
isExportableLocalStorageKey,
|
||||
keepValidTargetSession,
|
||||
markDomainMigrationCompleted,
|
||||
markDomainMigrationFailed,
|
||||
markDomainMigrationHandedOff,
|
||||
parseDomainMigrationMarker,
|
||||
parseDomainMigrationPayload,
|
||||
readDomainMigrationEnrollment,
|
||||
readDomainMigrationImport,
|
||||
readDomainMigrationIntent,
|
||||
readDomainMigrationMarker,
|
||||
resolveDomainMigrationSide,
|
||||
@@ -40,7 +52,10 @@ import {
|
||||
type StorageLike,
|
||||
sanitizeNextPath,
|
||||
shouldForwardCompletedSource,
|
||||
shouldStartDomainMigration,
|
||||
withoutOptionalPayloadData,
|
||||
writeDomainMigrationDeviceMap,
|
||||
writeDomainMigrationImport,
|
||||
writeDomainMigrationIntent,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationCore';
|
||||
import {
|
||||
@@ -52,6 +67,11 @@ import {
|
||||
sha256Hex,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationCrypto';
|
||||
import type {SoundType} from '@app/features/notification/utils/SoundUtils';
|
||||
import {
|
||||
AuthSessionStorageKey,
|
||||
parseStoredSessionValue,
|
||||
readStoredSessionUserId,
|
||||
} from '@app/features/platform/state/auth_session/AuthSessionStorage';
|
||||
import {getProtectedLocalStorage, getProtectedSessionStorage} from '@app/features/platform/state/ProtectedWebStorage';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import type {
|
||||
@@ -67,6 +87,11 @@ const NONCE_BYTES = 32;
|
||||
const DEVICE_ID_BYTES = 16;
|
||||
const BASE64URL_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/u;
|
||||
const MAX_HANDOFF_PAYLOAD_LENGTH = 8 * 1024 * 1024;
|
||||
const IMPORT_WAIT_MS = 20_000;
|
||||
const IMPORT_POLL_MS = 250;
|
||||
const MEDIA_DEVICES_WAIT_MS = 1500;
|
||||
|
||||
type DomainMigrationLanding = 'target' | 'source';
|
||||
|
||||
type DomainMigrationFailureReason =
|
||||
| 'disabled'
|
||||
@@ -97,6 +122,11 @@ function navigate(url: string): true {
|
||||
return true;
|
||||
}
|
||||
|
||||
function mountInPlace(next: string): false {
|
||||
window.history.replaceState(null, '', next);
|
||||
return false;
|
||||
}
|
||||
|
||||
function readCurrentPath(): {pathname: string; search: string; hash: string} {
|
||||
return {pathname: window.location.pathname, search: window.location.search, hash: window.location.hash};
|
||||
}
|
||||
@@ -155,6 +185,27 @@ async function restoreCustomSounds(sounds: ReadonlyArray<DomainMigrationCustomSo
|
||||
}
|
||||
}
|
||||
|
||||
async function collectMediaDevices(): Promise<Array<DomainMigrationMediaDevice> | undefined> {
|
||||
try {
|
||||
const devices = await Promise.race([
|
||||
navigator.mediaDevices?.enumerateDevices?.() ?? Promise.resolve([]),
|
||||
new Promise<Array<MediaDeviceInfo>>((resolve) => setTimeout(() => resolve([]), MEDIA_DEVICES_WAIT_MS)),
|
||||
]);
|
||||
const labelled = devices.flatMap((device): Array<DomainMigrationMediaDevice> => {
|
||||
const deviceId = device.deviceId.trim();
|
||||
const label = device.label.trim();
|
||||
if (!deviceId || !label || deviceId === 'default' || deviceId === 'communications') {
|
||||
return [];
|
||||
}
|
||||
return [{kind: device.kind, device_id: deviceId, label}];
|
||||
});
|
||||
return labelled.length > 0 ? labelled : undefined;
|
||||
} catch (err) {
|
||||
logger.warn('Skipping media devices in the domain migration export:', err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectThemeLibrary(): Promise<DomainMigrationThemeLibrary | undefined> {
|
||||
try {
|
||||
const db = await import('@app/features/theme/utils/ThemeLibraryDb');
|
||||
@@ -279,7 +330,7 @@ function discoveryAllowsMigration(): boolean {
|
||||
}
|
||||
|
||||
function revokeCompletedMarker(storage: StorageLike | null): void {
|
||||
if (readDomainMigrationMarker(storage)?.state === 'completed') {
|
||||
if (isCompletedDomainMigrationMarker(readDomainMigrationMarker(storage))) {
|
||||
markDomainMigrationFailed(storage, Date.now());
|
||||
}
|
||||
}
|
||||
@@ -296,11 +347,20 @@ async function exportFromSource(side: DomainMigrationSide, nonce: string | null)
|
||||
if (nonce === null || !BASE64URL_TOKEN_PATTERN.test(nonce)) {
|
||||
throw new Error('Missing or malformed nonce');
|
||||
}
|
||||
const [{default: accountStorage}, {default: RuntimeConfig}] = await Promise.all([
|
||||
import('@app/features/auth/state/AccountStorage'),
|
||||
import('@app/features/app/state/RuntimeConfig'),
|
||||
]);
|
||||
const accounts = (await accountStorage.getAllAccounts()).filter((account) => Boolean(account.token));
|
||||
const previous = readDomainMigrationMarker(storage);
|
||||
const attempts = previous?.state === 'handed-off' ? previous.attempts + 1 : Math.max(previous?.attempts ?? 0, 1);
|
||||
if (attempts > DOMAIN_MIGRATION_MAX_FAILED_ATTEMPTS) {
|
||||
throw new Error('Too many domain migration attempts');
|
||||
}
|
||||
const [{default: accountStorage}, {default: RuntimeConfig, runtimeConfigSnapshotsAreSameInstance}] =
|
||||
await Promise.all([
|
||||
import('@app/features/auth/state/AccountStorage'),
|
||||
import('@app/features/app/state/RuntimeConfig'),
|
||||
]);
|
||||
const instance = RuntimeConfig.getSnapshot();
|
||||
const accounts = (await accountStorage.getAllAccounts()).filter(
|
||||
(account) => Boolean(account.token) && runtimeConfigSnapshotsAreSameInstance(account.instance, instance),
|
||||
);
|
||||
const token = readActiveSessionToken() ?? accounts.find((account) => account.isValid !== false)?.token ?? null;
|
||||
if (!token) {
|
||||
throw new Error('No stored account to export');
|
||||
@@ -313,6 +373,7 @@ async function exportFromSource(side: DomainMigrationSide, nonce: string | null)
|
||||
accounts,
|
||||
custom_sounds: await collectCustomSounds(),
|
||||
theme_library: await collectThemeLibrary(),
|
||||
media_devices: await collectMediaDevices(),
|
||||
notification_permission: readNotificationPermission(),
|
||||
};
|
||||
let sealed = await encryptDomainMigrationPayload(payload);
|
||||
@@ -321,7 +382,14 @@ async function exportFromSource(side: DomainMigrationSide, nonce: string | null)
|
||||
}
|
||||
const handoffId = await createHandoff(RuntimeConfig.apiEndpoint, token, await sha256Hex(nonce), sealed.payload);
|
||||
writeDomainMigrationIntent(sessionStorage, {at: intent.at, handoff_id: handoffId});
|
||||
return navigate(`${side.target}/migrate/complete#h=${handoffId}&k=${sealed.key}`);
|
||||
markDomainMigrationHandedOff(storage, side.target, Date.now(), attempts);
|
||||
const landing: DomainMigrationLanding = environmentMayForward(
|
||||
readDomainMigrationEnvironment(),
|
||||
readDomainMigrationDiscovery(),
|
||||
)
|
||||
? 'target'
|
||||
: 'source';
|
||||
return navigate(`${side.target}/migrate/complete#h=${handoffId}&k=${sealed.key}&land=${landing}`);
|
||||
} catch (err) {
|
||||
logger.warn('Domain migration export failed:', err);
|
||||
clearDomainMigrationIntent(sessionStorage);
|
||||
@@ -330,20 +398,45 @@ async function exportFromSource(side: DomainMigrationSide, nonce: string | null)
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardCompletedSource(side: DomainMigrationSide): Promise<true> {
|
||||
const {pathname, search, hash} = readCurrentPath();
|
||||
if (!(await hasStoredAccount())) {
|
||||
return navigate(buildTargetUrl(side.target, pathname, search, hash));
|
||||
}
|
||||
writeDomainMigrationIntent(getProtectedSessionStorage(), {at: Date.now()});
|
||||
const next = sanitizeNextPath(`${pathname}${search}${hash}`);
|
||||
const imported = readDomainMigrationMarker(getProtectedLocalStorage())?.state === 'completed' ? '&imported=1' : '';
|
||||
return navigate(`${side.target}/migrate/begin?resume=1${imported}&next=${encodeURIComponent(next)}`);
|
||||
}
|
||||
|
||||
async function startEnrolledSource(side: DomainMigrationSide): Promise<boolean> {
|
||||
if (
|
||||
!readDomainMigrationEnrollment(getProtectedLocalStorage()) ||
|
||||
!shouldStartDomainMigration(readDomainMigrationGateInput(true, false))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!(await hasStoredAccount()) || !(await desktopPasskeysSupported())) {
|
||||
return false;
|
||||
}
|
||||
return startDomainMigrationFromSource(side);
|
||||
}
|
||||
|
||||
async function forwardFromSource(side: DomainMigrationSide): Promise<boolean> {
|
||||
const {pathname, search, hash} = readCurrentPath();
|
||||
if (isDomainMigrationOneShotRoute(pathname)) {
|
||||
return false;
|
||||
}
|
||||
if (await startEnrolledSource(side)) {
|
||||
return true;
|
||||
}
|
||||
const environment = readDomainMigrationEnvironment();
|
||||
const discovery = readDomainMigrationDiscovery();
|
||||
if (!environmentMayForward(environment, discovery)) {
|
||||
return false;
|
||||
}
|
||||
const {pathname, search, hash} = readCurrentPath();
|
||||
if (shouldForwardCompletedSource(discovery, readDomainMigrationMarker(getProtectedLocalStorage()), environment)) {
|
||||
if (!(await hasStoredAccount())) {
|
||||
return navigate(buildTargetUrl(side.target, pathname, search, hash));
|
||||
}
|
||||
writeDomainMigrationIntent(getProtectedSessionStorage(), {at: Date.now()});
|
||||
const next = sanitizeNextPath(`${pathname}${search}${hash}`);
|
||||
return navigate(`${side.target}/migrate/begin?resume=1&next=${encodeURIComponent(next)}`);
|
||||
return forwardCompletedSource(side);
|
||||
}
|
||||
if (
|
||||
discovery !== null &&
|
||||
@@ -360,6 +453,7 @@ async function forwardWhenIdle(side: DomainMigrationSide): Promise<void> {
|
||||
const {default: MediaEngine} = await import('@app/features/voice/engine/MediaEngineFacade');
|
||||
await when(() => !MediaEngine.connected && !MediaEngine.connecting);
|
||||
if (
|
||||
isDomainMigrationOneShotRoute(window.location.pathname) ||
|
||||
!shouldForwardCompletedSource(
|
||||
readDomainMigrationDiscovery(),
|
||||
readDomainMigrationMarker(getProtectedLocalStorage()),
|
||||
@@ -368,8 +462,7 @@ async function forwardWhenIdle(side: DomainMigrationSide): Promise<void> {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const {pathname, search, hash} = readCurrentPath();
|
||||
navigate(buildTargetUrl(side.target, pathname, search, hash));
|
||||
await forwardCompletedSource(side);
|
||||
}
|
||||
|
||||
function installSourceMarkerListener(side: DomainMigrationSide): void {
|
||||
@@ -486,18 +579,35 @@ async function importHandoff(side: DomainMigrationSide, fragment: URLSearchParam
|
||||
if (payload === null) {
|
||||
throw new DomainMigrationImportError('invalid_payload');
|
||||
}
|
||||
const storage = getProtectedLocalStorage();
|
||||
const previousImport = readDomainMigrationImport(storage);
|
||||
writeDomainMigrationImport(storage, {state: 'running', at: Date.now()});
|
||||
try {
|
||||
const [{default: accountStorage}, {default: RuntimeConfig}] = await Promise.all([
|
||||
import('@app/features/auth/state/AccountStorage'),
|
||||
import('@app/features/app/state/RuntimeConfig'),
|
||||
]);
|
||||
const instance = RuntimeConfig.getSnapshot();
|
||||
await accountStorage.importAccounts(payload.accounts.map((account) => rewriteImportedAccount(account, instance)));
|
||||
const activeToken = readActiveSessionToken();
|
||||
const activeUserId = storage ? readStoredSessionUserId(storage) : null;
|
||||
const incomingToken = parseStoredSessionValue(payload.local_storage[AuthSessionStorageKey.Token] ?? null);
|
||||
const incomingUserId = parseStoredSessionValue(payload.local_storage[AuthSessionStorageKey.UserId] ?? null);
|
||||
const existing = new Map((await accountStorage.getAllAccounts()).map((account) => [account.userId, account]));
|
||||
await accountStorage.importAccounts(
|
||||
payload.accounts.map((account) =>
|
||||
keepValidTargetSession(rewriteImportedAccount(account, instance), existing.get(account.userId)),
|
||||
),
|
||||
);
|
||||
if (activeToken !== null && activeUserId !== null && incomingToken !== null && incomingUserId !== activeUserId) {
|
||||
await accountStorage.stashAccountData(activeUserId, activeToken, undefined, instance).catch((err: unknown) => {
|
||||
logger.warn('Failed to keep the replaced target session:', err);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
writeDomainMigrationImport(storage, previousImport);
|
||||
logger.warn('Failed to import migrated accounts:', err);
|
||||
throw new DomainMigrationImportError('import_failed');
|
||||
}
|
||||
const storage = getProtectedLocalStorage();
|
||||
for (const [storageKey, value] of Object.entries(payload.local_storage)) {
|
||||
if (!isExportableLocalStorageKey(storageKey)) {
|
||||
continue;
|
||||
@@ -508,13 +618,17 @@ async function importHandoff(side: DomainMigrationSide, fragment: URLSearchParam
|
||||
logger.warn(`Failed to import localStorage key ${storageKey}:`, err);
|
||||
}
|
||||
}
|
||||
await restoreCustomSounds(payload.custom_sounds);
|
||||
await restoreThemeLibrary(payload.theme_library);
|
||||
if (payload.media_devices) {
|
||||
writeDomainMigrationDeviceMap(storage, {at: Date.now(), devices: payload.media_devices, resolved: []});
|
||||
}
|
||||
if (payload.notification_permission === 'granted') {
|
||||
try {
|
||||
storage?.setItem(DOMAIN_MIGRATION_NOTIFICATIONS_KEY, 'granted');
|
||||
} catch {}
|
||||
}
|
||||
writeDomainMigrationImport(storage, {state: 'done', at: Date.now()});
|
||||
await restoreCustomSounds(payload.custom_sounds);
|
||||
await restoreThemeLibrary(payload.theme_library);
|
||||
await persistDesktopAppOrigin();
|
||||
}
|
||||
|
||||
@@ -531,23 +645,50 @@ function doneUrl(side: DomainMigrationSide, next: string, handoffId: string | nu
|
||||
return `${side.source}/migrate/done?${handoff}next=${encodeURIComponent(next)}`;
|
||||
}
|
||||
|
||||
function landingUrl(side: DomainMigrationSide, fragment: URLSearchParams, next: string): string {
|
||||
switch (fragment.get('land')) {
|
||||
case 'target':
|
||||
return `${side.target}${next}`;
|
||||
case 'source':
|
||||
return sourceUrl(side, next);
|
||||
}
|
||||
return doneUrl(side, next, fragment.get('h'));
|
||||
}
|
||||
|
||||
function failedUrl(side: DomainMigrationSide, reason: DomainMigrationFailureReason, next: string): string {
|
||||
return `${side.source}/migrate/failed?reason=${reason}&next=${encodeURIComponent(next)}`;
|
||||
}
|
||||
|
||||
async function waitForRunningImport(): Promise<void> {
|
||||
const deadline = Date.now() + IMPORT_WAIT_MS;
|
||||
while (importIsRunning(readDomainMigrationImport(getProtectedLocalStorage()), Date.now()) && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, IMPORT_POLL_MS));
|
||||
}
|
||||
}
|
||||
|
||||
async function beginOnTarget(side: DomainMigrationSide, params: URLSearchParams): Promise<boolean> {
|
||||
const next = sanitizeNextPath(params.get('next'));
|
||||
if (readDomainMigrationDiscovery()?.enabled !== true) {
|
||||
return navigate(failedUrl(side, 'disabled', next));
|
||||
}
|
||||
const storage = getProtectedLocalStorage();
|
||||
const started = params.get('start') === '1';
|
||||
if (await hasStoredAccount()) {
|
||||
const resumed = params.get('resume') === '1';
|
||||
if (resumed) {
|
||||
await waitForRunningImport();
|
||||
}
|
||||
const imported = readDomainMigrationImport(storage)?.state === 'done' || (resumed && params.get('imported') === '1');
|
||||
if ((resumed || started) && (imported || (started && (await hasStoredAccount())))) {
|
||||
await persistDesktopAppOrigin();
|
||||
return navigate(params.get('resume') === '1' || started ? `${side.target}${next}` : doneUrl(side, next, null));
|
||||
return mountInPlace(next);
|
||||
}
|
||||
if (started) {
|
||||
return navigate(`${side.source}/migrate/start?next=${encodeURIComponent(next)}`);
|
||||
}
|
||||
if (imported) {
|
||||
return navigate(doneUrl(side, next, null));
|
||||
}
|
||||
writeDomainMigrationImport(storage, {state: 'running', at: Date.now()});
|
||||
const nonce = randomBase64Url(NONCE_BYTES);
|
||||
const pending: PendingHandoff = {nonce, next, at: Date.now()};
|
||||
getProtectedSessionStorage()?.setItem(DOMAIN_MIGRATION_PENDING_KEY, JSON.stringify(pending));
|
||||
@@ -559,22 +700,29 @@ async function completeOnTarget(side: DomainMigrationSide): Promise<boolean> {
|
||||
window.history.replaceState(window.history.state, '', `${window.location.pathname}${window.location.search}`);
|
||||
const pending = takePendingHandoff();
|
||||
if (pending === null || Date.now() - pending.at > DOMAIN_MIGRATION_PENDING_MAX_AGE_MS) {
|
||||
const lastImport = readDomainMigrationImport(getProtectedLocalStorage());
|
||||
if (lastImport?.state === 'done' && Date.now() - lastImport.at < DOMAIN_MIGRATION_PENDING_MAX_AGE_MS) {
|
||||
return navigate(
|
||||
fragment.get('land') === 'source'
|
||||
? sourceUrl(side, DOMAIN_MIGRATION_DEFAULT_NEXT_PATH)
|
||||
: `${side.target}${DOMAIN_MIGRATION_DEFAULT_NEXT_PATH}`,
|
||||
);
|
||||
}
|
||||
return navigate(failedUrl(side, 'no_pending', DOMAIN_MIGRATION_DEFAULT_NEXT_PATH));
|
||||
}
|
||||
const next = sanitizeNextPath(pending.next);
|
||||
const handoffId = fragment.get('h');
|
||||
if (await hasStoredAccount()) {
|
||||
await persistDesktopAppOrigin();
|
||||
return navigate(doneUrl(side, next, handoffId));
|
||||
}
|
||||
try {
|
||||
await importHandoff(side, fragment, pending.nonce);
|
||||
} catch (err) {
|
||||
const reason = err instanceof DomainMigrationImportError ? err.reason : 'import_failed';
|
||||
logger.warn('Domain migration import failed:', err);
|
||||
const storage = getProtectedLocalStorage();
|
||||
if (readDomainMigrationImport(storage)?.state === 'running') {
|
||||
writeDomainMigrationImport(storage, null);
|
||||
}
|
||||
return navigate(failedUrl(side, reason, next));
|
||||
}
|
||||
return navigate(doneUrl(side, next, handoffId));
|
||||
return navigate(landingUrl(side, fragment, next));
|
||||
}
|
||||
|
||||
async function handleTarget(side: DomainMigrationSide): Promise<boolean> {
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
markDomainMigrationEnrolled,
|
||||
readDomainMigrationEnrollment,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationCore';
|
||||
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
import {getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {readDomainMigrationAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {
|
||||
INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
|
||||
readDomainMigrationAssignment,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {makeAutoObservable} from 'mobx';
|
||||
|
||||
const logger = new Logger('DomainMigrationRollout');
|
||||
|
||||
class DomainMigrationRolloutSelector {
|
||||
get enabled(): boolean {
|
||||
deviceEnrolled = readDomainMigrationEnrollment(getProtectedLocalStorage());
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
}
|
||||
|
||||
get assignmentReady(): boolean {
|
||||
return (
|
||||
ExperimentAssignments.response !== INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE &&
|
||||
ExperimentAssignments.ownerId === SessionManager.userId
|
||||
);
|
||||
}
|
||||
|
||||
get assignmentEnabled(): boolean {
|
||||
if (!this.assignmentReady) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return readDomainMigrationAssignment(ExperimentAssignments.response).enabled;
|
||||
} catch (err) {
|
||||
@@ -15,6 +41,15 @@ class DomainMigrationRolloutSelector {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this.assignmentEnabled || this.deviceEnrolled;
|
||||
}
|
||||
|
||||
markDeviceEnrolled(): void {
|
||||
markDomainMigrationEnrolled(getProtectedLocalStorage(), Date.now());
|
||||
this.deviceEnrolled = true;
|
||||
}
|
||||
}
|
||||
|
||||
export const DomainMigrationRollout = new DomainMigrationRolloutSelector();
|
||||
|
||||
@@ -1,80 +1,158 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import {
|
||||
desktopPasskeysSupported,
|
||||
readDomainMigrationDiscovery,
|
||||
readDomainMigrationEnvironment,
|
||||
readDomainMigrationGateInput,
|
||||
startDomainMigrationFromSource,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationBrowser';
|
||||
import {
|
||||
DOMAIN_MIGRATION_IMPORT_KEY,
|
||||
DOMAIN_MIGRATION_NOTIFICATIONS_KEY,
|
||||
type DomainMigrationGateInput,
|
||||
type DomainMigrationSide,
|
||||
markDomainMigrationFailed,
|
||||
readDomainMigrationMarker,
|
||||
domainMigrationProbeIsDue,
|
||||
environmentAllowsDomainMigration,
|
||||
markDomainMigrationProbed,
|
||||
markerAllowsDomainMigration,
|
||||
readDomainMigrationImport,
|
||||
resolveDomainMigrationSide,
|
||||
shouldStartDomainMigration,
|
||||
writeDomainMigrationIntent,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationCore';
|
||||
import DomainMigrationRollout from '@app/features/app/domain_migration/DomainMigrationRollout';
|
||||
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
|
||||
import {getProtectedLocalStorage, getProtectedSessionStorage} from '@app/features/platform/state/ProtectedWebStorage';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import {EXPERIMENT_ASSIGNMENTS_PATH} from '@app/features/experiment/state/ExperimentAssignments';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
import {getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as NagbarCommands from '@app/features/ui/commands/NagbarCommands';
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {when} from 'mobx';
|
||||
import {
|
||||
ExperimentAssignmentsResponse,
|
||||
readDomainMigrationAssignment,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {compareShallow, reaction} from 'mobx';
|
||||
|
||||
const logger = new Logger('DomainMigrationTrigger');
|
||||
|
||||
const ONE_SHOT_ROUTE_PREFIXES: ReadonlyArray<string> = [
|
||||
Routes.RESET_PASSWORD,
|
||||
Routes.VERIFY_EMAIL,
|
||||
Routes.AUTHORIZE_IP,
|
||||
Routes.EMAIL_REVERT,
|
||||
Routes.OAUTH_AUTHORIZE,
|
||||
Routes.SSO_CALLBACK,
|
||||
Routes.PREMIUM_CALLBACK,
|
||||
Routes.AGE_VERIFICATION_CALLBACK,
|
||||
Routes.CONNECTION_CALLBACK,
|
||||
];
|
||||
|
||||
let started = false;
|
||||
let navigating = false;
|
||||
let probing = false;
|
||||
|
||||
interface SourceGateState {
|
||||
assignmentReady: boolean;
|
||||
assignmentEnabled: boolean;
|
||||
deviceEnrolled: boolean;
|
||||
userId: string | null;
|
||||
sessionSettled: boolean;
|
||||
voiceActive: boolean;
|
||||
}
|
||||
|
||||
function isVoiceActive(): boolean {
|
||||
return MediaEngine.connected || MediaEngine.connecting;
|
||||
}
|
||||
|
||||
function isOneShotRoute(pathname: string): boolean {
|
||||
return ONE_SHOT_ROUTE_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
|
||||
function isSessionSettled(): boolean {
|
||||
return SessionManager.isAuthenticated && SessionManager.userId !== null && !AccountManager.transitioning;
|
||||
}
|
||||
|
||||
function readGateInput(assignmentEnabled: boolean): DomainMigrationGateInput {
|
||||
return {
|
||||
environment: readDomainMigrationEnvironment(),
|
||||
assignmentEnabled,
|
||||
discovery: readDomainMigrationDiscovery(),
|
||||
marker: readDomainMigrationMarker(getProtectedLocalStorage()),
|
||||
now: Date.now(),
|
||||
voiceActive: isVoiceActive(),
|
||||
oneShotRoute: isOneShotRoute(window.location.pathname),
|
||||
};
|
||||
function sourceMayStart(): boolean {
|
||||
return (
|
||||
!navigating &&
|
||||
isSessionSettled() &&
|
||||
shouldStartDomainMigration(readDomainMigrationGateInput(DomainMigrationRollout.enabled, isVoiceActive()))
|
||||
);
|
||||
}
|
||||
|
||||
async function evaluateSource(side: DomainMigrationSide, assignmentEnabled: boolean): Promise<void> {
|
||||
if (navigating || !shouldStartDomainMigration(readGateInput(assignmentEnabled))) {
|
||||
async function evaluateSource(side: DomainMigrationSide): Promise<void> {
|
||||
if (!sourceMayStart()) {
|
||||
return;
|
||||
}
|
||||
const passkeysSupported = await desktopPasskeysSupported();
|
||||
if (navigating || !passkeysSupported || !shouldStartDomainMigration(readGateInput(DomainMigrationRollout.enabled))) {
|
||||
if (!passkeysSupported || !sourceMayStart()) {
|
||||
return;
|
||||
}
|
||||
navigating = true;
|
||||
markDomainMigrationFailed(getProtectedLocalStorage(), Date.now());
|
||||
writeDomainMigrationIntent(getProtectedSessionStorage(), {at: Date.now()});
|
||||
const next = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
window.location.replace(`${side.target}/migrate/begin?next=${encodeURIComponent(next)}`);
|
||||
startDomainMigrationFromSource(side);
|
||||
}
|
||||
|
||||
async function accountIsEnrolled(token: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${RuntimeConfig.apiEndpoint}/v${RuntimeConfig.apiCodeVersion}${EXPERIMENT_ASSIGNMENTS_PATH}`,
|
||||
{credentials: 'omit', headers: {Authorization: token}},
|
||||
);
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
const parsed = ExperimentAssignmentsResponse.safeParse(await response.json());
|
||||
return parsed.success && readDomainMigrationAssignment(parsed.data).enabled;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function probeStoredAccounts(): Promise<void> {
|
||||
const storage = getProtectedLocalStorage();
|
||||
const gate = readDomainMigrationGateInput(false, false);
|
||||
if (
|
||||
probing ||
|
||||
DomainMigrationRollout.deviceEnrolled ||
|
||||
gate.discovery?.enabled !== true ||
|
||||
!environmentAllowsDomainMigration(gate.environment) ||
|
||||
!markerAllowsDomainMigration(gate.marker, gate.now) ||
|
||||
!domainMigrationProbeIsDue(storage, gate.now)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const activeUserId = SessionManager.userId;
|
||||
const tokens = SessionManager.accounts
|
||||
.filter((account) => account.userId !== activeUserId && account.isValid !== false && Boolean(account.token))
|
||||
.map((account) => account.token);
|
||||
if (tokens.length === 0) {
|
||||
return;
|
||||
}
|
||||
probing = true;
|
||||
markDomainMigrationProbed(storage, gate.now);
|
||||
try {
|
||||
for (const token of tokens) {
|
||||
if (await accountIsEnrolled(token)) {
|
||||
DomainMigrationRollout.markDeviceEnrolled();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
probing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function readSourceGateState(): SourceGateState {
|
||||
return {
|
||||
assignmentReady: DomainMigrationRollout.assignmentReady,
|
||||
assignmentEnabled: DomainMigrationRollout.assignmentEnabled,
|
||||
deviceEnrolled: DomainMigrationRollout.deviceEnrolled,
|
||||
userId: SessionManager.userId,
|
||||
sessionSettled: isSessionSettled(),
|
||||
voiceActive: isVoiceActive(),
|
||||
};
|
||||
}
|
||||
|
||||
function handleSourceGateState(side: DomainMigrationSide, state: SourceGateState): void {
|
||||
if (state.assignmentEnabled && !state.deviceEnrolled) {
|
||||
DomainMigrationRollout.markDeviceEnrolled();
|
||||
return;
|
||||
}
|
||||
if (navigating || !state.sessionSettled || (!state.assignmentReady && !state.deviceEnrolled)) {
|
||||
return;
|
||||
}
|
||||
if (!state.deviceEnrolled) {
|
||||
probeStoredAccounts().catch((err) => {
|
||||
logger.warn('Domain migration enrollment probe failed:', err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
evaluateSource(side).catch((err) => {
|
||||
logger.warn('Domain migration trigger failed:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function offerNotificationReenable(): void {
|
||||
@@ -92,6 +170,27 @@ function offerNotificationReenable(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function reloadAfterSiblingImport(event: StorageEvent): void {
|
||||
if (
|
||||
event.key === DOMAIN_MIGRATION_IMPORT_KEY &&
|
||||
readDomainMigrationImport(getProtectedLocalStorage())?.state === 'done'
|
||||
) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
export function probeSignedOutDeviceEnrollment(): void {
|
||||
if (typeof window === 'undefined' || SessionManager.isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
if (resolveDomainMigrationSide(window.location.origin)?.role !== 'source') {
|
||||
return;
|
||||
}
|
||||
probeStoredAccounts().catch((err) => {
|
||||
logger.warn('Domain migration enrollment probe failed:', err);
|
||||
});
|
||||
}
|
||||
|
||||
export function startDomainMigrationTrigger(): void {
|
||||
if (started || typeof window === 'undefined') {
|
||||
return;
|
||||
@@ -103,14 +202,11 @@ export function startDomainMigrationTrigger(): void {
|
||||
started = true;
|
||||
if (side.role === 'target') {
|
||||
setTimeout(offerNotificationReenable, 0);
|
||||
window.addEventListener('storage', reloadAfterSiblingImport);
|
||||
return;
|
||||
}
|
||||
when(
|
||||
() => ExperimentAssignments.response !== INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
|
||||
() => {
|
||||
evaluateSource(side, DomainMigrationRollout.enabled).catch((err) => {
|
||||
logger.warn('Domain migration trigger failed:', err);
|
||||
});
|
||||
},
|
||||
);
|
||||
reaction(readSourceGateState, (state) => handleSourceGateState(side, state), {
|
||||
fireImmediately: true,
|
||||
equals: compareShallow,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1156,6 +1156,11 @@ class KeybindManager {
|
||||
return false;
|
||||
}
|
||||
this.globalKeyHookStarted = true;
|
||||
if (NativePermission.isLinuxWaylandDesktop) {
|
||||
void NativePermission.recheckLinuxInputAccess().then((status) => {
|
||||
if (status === 'blocked') NativePermission.requestLinuxInputAccessNagbar(reason);
|
||||
});
|
||||
}
|
||||
const keyEventUnsub = electronApi.onGlobalKeyEvent?.((event) => {
|
||||
this.handleGlobalKeyEvent(
|
||||
event as {
|
||||
|
||||
@@ -100,6 +100,8 @@ export function runtimeConfigSnapshotsAreSameInstance(
|
||||
const DEFAULT_INSTANCE_FEATURES: InstanceFeatures = {
|
||||
voice_enabled: false,
|
||||
stripe_enabled: false,
|
||||
premium_enabled: false,
|
||||
stripe_serviceable: false,
|
||||
self_hosted: false,
|
||||
presigned_attachment_uploads: false,
|
||||
emails_enabled: false,
|
||||
@@ -147,6 +149,8 @@ export const DEFAULT_APP_PUBLIC_CONFIG: InstanceAppPublic = {
|
||||
theme_color: null,
|
||||
status_page_url: null,
|
||||
status_page_incident_history_url: null,
|
||||
premium_product_name: 'Plutonium',
|
||||
premium_info_url: null,
|
||||
},
|
||||
setup: {
|
||||
configured: false,
|
||||
@@ -398,6 +402,9 @@ class RuntimeConfig {
|
||||
this.features = {
|
||||
...this.features,
|
||||
self_hosted: config.self_hosted,
|
||||
premium_enabled: !config.self_hosted || config.policy.premium_mode === 'mirror',
|
||||
stripe_enabled: config.billing.billing_active,
|
||||
stripe_serviceable: config.billing.stripe_serviceable,
|
||||
};
|
||||
this.registration = normalizeInstanceRegistration(config.registration);
|
||||
this.community = normalizeInstanceCommunity({
|
||||
@@ -495,6 +502,28 @@ class RuntimeConfig {
|
||||
return DeveloperOptions.selfHostedModeOverride || this.features.self_hosted;
|
||||
}
|
||||
|
||||
get premiumEnabled(): boolean {
|
||||
return this.features.premium_enabled;
|
||||
}
|
||||
|
||||
get stripeEnabled(): boolean {
|
||||
return this.features.stripe_enabled;
|
||||
}
|
||||
|
||||
get stripeServiceable(): boolean {
|
||||
return this.features.stripe_serviceable;
|
||||
}
|
||||
|
||||
get premiumProductName(): string {
|
||||
return (
|
||||
this.appPublic.branding.premium_product_name?.trim() || DEFAULT_APP_PUBLIC_CONFIG.branding.premium_product_name
|
||||
);
|
||||
}
|
||||
|
||||
get premiumInfoUrl(): string | null {
|
||||
return this.appPublic.branding.premium_info_url ?? null;
|
||||
}
|
||||
|
||||
get emailsEnabled(): boolean {
|
||||
return this.features.emails_enabled;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
text-align: start;
|
||||
background-color: var(--background-secondary);
|
||||
border: 0.0625rem solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export const AccountRow = observer(
|
||||
const variantClassName = variant === 'manage' ? styles.manage : variant === 'compact' ? styles.compact : undefined;
|
||||
const isClickable = typeof onClick === 'function';
|
||||
const MainButtonComponent = isClickable ? 'button' : 'div';
|
||||
const showMenuButton = Boolean(onMenuClick && variant !== 'compact' && !showCaretIndicator);
|
||||
const showMenuButton = Boolean(onMenuClick && variant !== 'compact');
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.row, variantClassName, showMenuButton && styles.withMenu, className)}
|
||||
|
||||
@@ -32,10 +32,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.accountListWrapper {
|
||||
min-height: 7.5rem;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
max-height: 17.5rem;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user