mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-10 04:32:34 +09:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c488906131 | ||
|
|
39c72f0fb0 | ||
|
|
3736d94d73 | ||
|
|
192cec689a | ||
|
|
e895c41bf0 | ||
|
|
997d98c65c | ||
|
|
c9ae5b6ee8 | ||
|
|
a728be4062 | ||
|
|
fce81367fb | ||
|
|
5a4edc0b59 | ||
|
|
713ae5f7f5 | ||
|
|
eaee820216 | ||
|
|
564c5ae164 | ||
|
|
dd8ed6f205 | ||
|
|
ed8c412415 | ||
|
|
12417a6942 | ||
|
|
d05f6c9aaa | ||
|
|
0ca035c547 | ||
|
|
dfd46ccc2c | ||
|
|
f6df3169ca | ||
|
|
5b280898c5 | ||
|
|
2a9e25c788 | ||
|
|
463c03fb6d | ||
|
|
153dad11e1 |
@@ -336,6 +336,9 @@ jobs:
|
||||
restore-keys: |
|
||||
rebar3-${{ runner.os }}-otp28-rebar3.27.0-
|
||||
|
||||
- name: Drop restored gateway build output
|
||||
run: rm -rf fluxer_gateway/_build/default/lib/fluxer_gateway fluxer_gateway/_build/test/lib/fluxer_gateway
|
||||
|
||||
- name: Check formatting
|
||||
run: |
|
||||
"$FLUXER_CI_BIN" ci --step gateway_fmt
|
||||
|
||||
+239
-174
@@ -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,10 +10523,10 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
|
||||
"push_service_delivery": {"$ref": "#/components/schemas/PushServiceDeliveryConfigResponse"},
|
||||
"push_relay": {"$ref": "#/components/schemas/PushRelayConfigResponse"},
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
|
||||
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaConfigResponse"},
|
||||
"profile_timezone": {"$ref": "#/components/schemas/ProfileTimezoneConfigResponse"},
|
||||
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
|
||||
"registration": {
|
||||
"type": "object",
|
||||
@@ -10638,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",
|
||||
@@ -10649,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
|
||||
},
|
||||
@@ -10948,22 +10952,24 @@
|
||||
},
|
||||
"required": ["attachment_decay"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"billing": {"$ref": "#/components/schemas/InstanceBillingResponse"}
|
||||
},
|
||||
"required": [
|
||||
"sso",
|
||||
"gateway_rollout",
|
||||
"voice_noise_suppression",
|
||||
"push_service_delivery",
|
||||
"push_relay",
|
||||
"domain_migration",
|
||||
"altcha_captcha",
|
||||
"profile_timezone",
|
||||
"experiment_delivery",
|
||||
"registration",
|
||||
"self_hosted",
|
||||
"app_public",
|
||||
"policy",
|
||||
"integrations",
|
||||
"media"
|
||||
"media",
|
||||
"billing"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -11087,14 +11093,7 @@
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/GatewayRolloutConfigUpdateRequest"}]
|
||||
},
|
||||
"voice_noise_suppression": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
|
||||
},
|
||||
"push_service_delivery": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/PushServiceDeliveryConfigUpdateRequest"}]
|
||||
},
|
||||
"push_relay": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/PushRelayConfigUpdateRequest"}]},
|
||||
"domain_migration": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
|
||||
@@ -11103,6 +11102,10 @@
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/AltchaCaptchaConfigUpdateRequest"}]
|
||||
},
|
||||
"profile_timezone": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ProfileTimezoneConfigUpdateRequest"}]
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
|
||||
@@ -11150,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"}}},
|
||||
@@ -11318,7 +11323,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"billing": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/InstanceBillingUpdateRequest"}]}
|
||||
}
|
||||
},
|
||||
"ListGuildStickersResponse": {
|
||||
@@ -15177,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",
|
||||
@@ -15196,6 +15224,30 @@
|
||||
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
|
||||
}
|
||||
},
|
||||
"ProfileTimezoneConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
|
||||
"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}$"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"AltchaCaptchaConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15207,6 +15259,12 @@
|
||||
"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",
|
||||
@@ -15228,6 +15286,12 @@
|
||||
"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",
|
||||
@@ -15237,63 +15301,7 @@
|
||||
"standalone_forwarding": {"type": "boolean"}
|
||||
}
|
||||
},
|
||||
"PushServiceDeliveryConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"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}$"}
|
||||
},
|
||||
"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}
|
||||
}
|
||||
},
|
||||
"PushRelayConfigUpdateRequest": {"type": "object", "properties": {"relay_consent_accepted": {"type": "boolean"}}},
|
||||
"GatewayRolloutConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15308,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",
|
||||
@@ -15321,6 +15404,51 @@
|
||||
"required": ["poll_interval_seconds", "poll_jitter_percent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProfileTimezoneConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {
|
||||
"default": "profile-timezone-v1",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[\\x20-\\x7e]+$"
|
||||
},
|
||||
"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}$"}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"included_guild_ids",
|
||||
"include_premium_users",
|
||||
"excluded_user_ids"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AltchaCaptchaConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15340,6 +15468,13 @@
|
||||
"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,
|
||||
@@ -15356,6 +15491,8 @@
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"included_guild_ids",
|
||||
"include_premium_users",
|
||||
"excluded_user_ids",
|
||||
"anonymous_enabled",
|
||||
"cost",
|
||||
@@ -15382,6 +15519,13 @@
|
||||
"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,
|
||||
@@ -15397,37 +15541,17 @@
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"included_guild_ids",
|
||||
"include_premium_users",
|
||||
"excluded_user_ids",
|
||||
"anonymous_rollout_basis_points",
|
||||
"standalone_forwarding"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PushServiceDeliveryConfigResponse": {
|
||||
"PushRelayConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {
|
||||
"default": "push-service-delivery-v1",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[\\x20-\\x7e]+$"
|
||||
},
|
||||
"included_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"relay_consent_accepted": {"default": false, "type": "boolean"},
|
||||
"relay_consent_accepted_at": {
|
||||
"default": null,
|
||||
@@ -15438,78 +15562,7 @@
|
||||
},
|
||||
"relay_consent_accepted_by": {"default": null, "nullable": true, "type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"excluded_user_ids",
|
||||
"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}$"}
|
||||
},
|
||||
"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",
|
||||
"excluded_user_ids",
|
||||
"guild_overrides",
|
||||
"suppression_strength"
|
||||
],
|
||||
"required": ["relay_consent_accepted", "relay_consent_accepted_at", "relay_consent_accepted_by"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"GatewayRolloutConfigResponse": {
|
||||
@@ -15542,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,15 +21,17 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub media: InstanceMediaResponse,
|
||||
#[serde(default)]
|
||||
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
|
||||
#[serde(default)]
|
||||
pub push_service_delivery: PushServiceDeliveryConfigResponse,
|
||||
pub push_relay: PushRelayConfigResponse,
|
||||
#[serde(default)]
|
||||
pub domain_migration: DomainMigrationConfigResponse,
|
||||
#[serde(default)]
|
||||
pub altcha_captcha: AltchaCaptchaConfigResponse,
|
||||
#[serde(default)]
|
||||
pub profile_timezone: ProfileTimezoneConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
#[serde(default)]
|
||||
pub billing: InstanceBillingResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -334,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 {
|
||||
@@ -348,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -356,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)]
|
||||
@@ -453,143 +464,22 @@ impl VoiceE2eeScope {
|
||||
}
|
||||
|
||||
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
|
||||
pub const PUSH_SERVICE_DELIVERY_DEFAULT_SALT: &str = "push-service-delivery-v1";
|
||||
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
|
||||
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 VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
|
||||
pub const PROFILE_TIMEZONE_DEFAULT_SALT: &str = "profile-timezone-v1";
|
||||
|
||||
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)]
|
||||
#[derive(Clone, Debug, Default, 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 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(),
|
||||
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 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, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct PushServiceDeliveryConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
pub struct PushRelayConfigResponse {
|
||||
pub relay_consent_accepted: bool,
|
||||
pub relay_consent_accepted_at: Option<String>,
|
||||
pub relay_consent_accepted_by: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for PushServiceDeliveryConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: PUSH_SERVICE_DELIVERY_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: None,
|
||||
relay_consent_accepted_by: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct PushServiceDeliveryConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: 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 excluded_user_ids: Option<Vec<String>>,
|
||||
pub struct PushRelayConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub relay_consent_accepted: Option<bool>,
|
||||
}
|
||||
@@ -602,6 +492,8 @@ pub struct DomainMigrationConfigResponse {
|
||||
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 anonymous_rollout_basis_points: u32,
|
||||
pub standalone_forwarding: bool,
|
||||
@@ -615,6 +507,8 @@ impl Default for DomainMigrationConfigResponse {
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: DOMAIN_MIGRATION_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
include_premium_users: false,
|
||||
excluded_user_ids: Vec::new(),
|
||||
anonymous_rollout_basis_points: 0,
|
||||
standalone_forwarding: false,
|
||||
@@ -633,6 +527,10 @@ pub struct DomainMigrationConfigUpdateRequest {
|
||||
#[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 anonymous_rollout_basis_points: Option<u32>,
|
||||
@@ -648,6 +546,8 @@ pub struct AltchaCaptchaConfigResponse {
|
||||
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 anonymous_enabled: bool,
|
||||
pub cost: u32,
|
||||
@@ -662,6 +562,8 @@ impl Default for AltchaCaptchaConfigResponse {
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: ALTCHA_CAPTCHA_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
include_premium_users: false,
|
||||
excluded_user_ids: Vec::new(),
|
||||
anonymous_enabled: false,
|
||||
cost: 5_000,
|
||||
@@ -681,6 +583,10 @@ pub struct AltchaCaptchaConfigUpdateRequest {
|
||||
#[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 anonymous_enabled: Option<bool>,
|
||||
@@ -690,6 +596,52 @@ pub struct AltchaCaptchaConfigUpdateRequest {
|
||||
pub max_counter: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ProfileTimezoneConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
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>,
|
||||
}
|
||||
|
||||
impl Default for ProfileTimezoneConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: PROFILE_TIMEZONE_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
include_premium_users: false,
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ProfileTimezoneConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: 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>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ExperimentDeliveryConfigResponse {
|
||||
@@ -804,15 +756,17 @@ 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_service_delivery: Option<PushServiceDeliveryConfigUpdateRequest>,
|
||||
pub push_relay: Option<PushRelayConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub altcha_captcha: Option<AltchaCaptchaConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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)]
|
||||
@@ -1021,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)]
|
||||
@@ -1125,56 +1083,37 @@ 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!({}))
|
||||
.expect("default altcha captcha config");
|
||||
let profile_timezone = serde_json::from_value::<ProfileTimezoneConfigResponse>(json!({}))
|
||||
.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 =
|
||||
serde_json::to_value(altcha_captcha).expect("serializable altcha captcha config");
|
||||
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");
|
||||
let generated_altcha_captcha: generated_types::AltchaCaptchaConfigResponse =
|
||||
serde_json::from_value(altcha_captcha.clone())
|
||||
.expect("generated altcha captcha config contract");
|
||||
let generated_profile_timezone: generated_types::ProfileTimezoneConfigResponse =
|
||||
serde_json::from_value(profile_timezone.clone())
|
||||
.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"),
|
||||
@@ -1185,15 +1124,20 @@ mod tests {
|
||||
.expect("serializable generated altcha captcha config"),
|
||||
altcha_captcha
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_profile_timezone)
|
||||
.expect("serializable generated profile timezone config"),
|
||||
profile_timezone
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_delivery)
|
||||
.expect("serializable generated delivery config"),
|
||||
delivery
|
||||
);
|
||||
for (name, value) in [
|
||||
("VoiceNoiseSuppressionConfigResponse", noise),
|
||||
("DomainMigrationConfigResponse", domain_migration),
|
||||
("AltchaCaptchaConfigResponse", altcha_captcha),
|
||||
("ProfileTimezoneConfigResponse", profile_timezone),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
] {
|
||||
for (field, value) in value.as_object().expect("config object") {
|
||||
@@ -1205,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, PushServiceDeliveryConfigUpdateRequest, 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,14 +206,18 @@ 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),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_push_service_delivery" => match build_push_service_delivery_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" => {
|
||||
let update = build_push_relay_update(&form);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
}
|
||||
"update_domain_migration" => match build_domain_migration_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
@@ -221,6 +226,10 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_profile_timezone" => match build_profile_timezone_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_experiment_delivery" => match build_experiment_delivery_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
@@ -348,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,
|
||||
@@ -462,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;
|
||||
@@ -503,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 {
|
||||
@@ -553,144 +563,13 @@ 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,
|
||||
});
|
||||
fn build_push_relay_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
InstanceConfigUpdateRequest {
|
||||
push_relay: Some(PushRelayConfigUpdateRequest {
|
||||
relay_consent_accepted: Some(form.bool_value("push_relay_consent_accepted")),
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
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",
|
||||
)?),
|
||||
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_service_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
push_service_delivery: Some(PushServiceDeliveryConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("push_service_delivery_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"push_service_delivery_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_ascii_experiment_rollout_salt(
|
||||
form,
|
||||
"push_service_delivery_rollout_salt",
|
||||
)?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("push_service_delivery_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("push_service_delivery_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
relay_consent_accepted: Some(
|
||||
form.bool_value("push_service_delivery_relay_consent_accepted"),
|
||||
),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_domain_migration_update(
|
||||
@@ -706,15 +585,18 @@ 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(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
included_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("domain_migration_included_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included guild IDs",
|
||||
)?),
|
||||
include_premium_users: Some(form.bool_value("domain_migration_include_premium_users")),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("domain_migration_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
@@ -746,12 +628,18 @@ 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(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
included_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("altcha_captcha_included_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included guild IDs",
|
||||
)?),
|
||||
include_premium_users: Some(form.bool_value("altcha_captcha_include_premium_users")),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("altcha_captcha_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
@@ -777,6 +665,41 @@ fn build_altcha_captcha_update(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_profile_timezone_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
profile_timezone: Some(ProfileTimezoneConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("profile_timezone_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"profile_timezone_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
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(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
included_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("profile_timezone_included_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included guild IDs",
|
||||
)?),
|
||||
include_premium_users: Some(form.bool_value("profile_timezone_include_premium_users")),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("profile_timezone_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
@@ -833,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")),
|
||||
@@ -1383,75 +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": [],
|
||||
"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!(
|
||||
@@ -1511,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(
|
||||
@@ -1737,6 +1405,8 @@ mod tests {
|
||||
serde_json::json!({"domain_migration": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"excluded_user_ids": [],
|
||||
"standalone_forwarding": false,
|
||||
}})
|
||||
@@ -1762,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",
|
||||
@@ -1784,26 +1458,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_push_service_delivery_update_reads_the_relay_consent_checkbox() {
|
||||
let unchecked = MultiValueForm::parse(b"_csrf=token");
|
||||
fn build_push_relay_update_reads_the_consent_checkbox() {
|
||||
let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token"));
|
||||
assert_eq!(
|
||||
build_push_service_delivery_update(&unchecked)
|
||||
.expect("valid form")
|
||||
.push_service_delivery
|
||||
.expect("push service delivery update")
|
||||
.relay_consent_accepted,
|
||||
Some(false)
|
||||
serde_json::to_value(&unchecked).expect("serialize update"),
|
||||
serde_json::json!({"push_relay": {"relay_consent_accepted": false}})
|
||||
);
|
||||
|
||||
let checked =
|
||||
MultiValueForm::parse(b"_csrf=token&push_service_delivery_relay_consent_accepted=true");
|
||||
let checked = build_push_relay_update(&MultiValueForm::parse(
|
||||
b"_csrf=token&push_relay_consent_accepted=true",
|
||||
));
|
||||
assert_eq!(
|
||||
build_push_service_delivery_update(&checked)
|
||||
.expect("valid form")
|
||||
.push_service_delivery
|
||||
.expect("push service delivery update")
|
||||
.relay_consent_accepted,
|
||||
Some(true)
|
||||
serde_json::to_value(&checked).expect("serialize update"),
|
||||
serde_json::json!({"push_relay": {"relay_consent_accepted": true}})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1841,6 +1508,8 @@ mod tests {
|
||||
serde_json::json!({"altcha_captcha": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"excluded_user_ids": [],
|
||||
"anonymous_enabled": false,
|
||||
}})
|
||||
@@ -1871,6 +1540,83 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_profile_timezone_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"profile_timezone_enabled=true&profile_timezone_rollout_basis_points=%20500%20&profile_timezone_rollout_salt=%20profile-timezone-v2%20&profile_timezone_included_user_ids=1500000000000000001&profile_timezone_excluded_user_ids=1500000000000000002&profile_timezone_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&profile_timezone_include_premium_users=true",
|
||||
);
|
||||
let update = build_profile_timezone_update(&form)
|
||||
.expect("valid form")
|
||||
.profile_timezone
|
||||
.expect("profile timezone update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(500));
|
||||
assert_eq!(update.rollout_salt, Some("profile-timezone-v2".to_owned()));
|
||||
assert_eq!(update.include_premium_users, Some(true));
|
||||
assert_eq!(
|
||||
update.included_guild_ids,
|
||||
Some(vec![
|
||||
"1500000000000000005".to_owned(),
|
||||
"1500000000000000006".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec!["1500000000000000002".to_owned()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_profile_timezone_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_profile_timezone_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"profile_timezone": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"excluded_user_ids": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_experiment_update_rejects_an_invalid_included_guild_id() {
|
||||
for (prefix, build) in [
|
||||
(
|
||||
"domain_migration",
|
||||
build_domain_migration_update
|
||||
as fn(&MultiValueForm) -> Result<InstanceConfigUpdateRequest, String>,
|
||||
),
|
||||
("altcha_captcha", build_altcha_captcha_update),
|
||||
("profile_timezone", build_profile_timezone_update),
|
||||
] {
|
||||
let form = MultiValueForm::parse(
|
||||
format!("{prefix}_included_guild_ids=1500000000000000005%0Anot-a-guild").as_bytes(),
|
||||
);
|
||||
assert_eq!(
|
||||
build(&form).expect_err("invalid guild id"),
|
||||
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
"{prefix}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_profile_timezone_update_rejects_a_rollout_above_everybody() {
|
||||
let form = MultiValueForm::parse(b"profile_timezone_rollout_basis_points=10001");
|
||||
assert_eq!(
|
||||
build_profile_timezone_update(&form).expect_err("invalid field"),
|
||||
"Rollout basis points must be a whole number between 0 and 10000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
|
||||
@@ -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,
|
||||
PUSH_SERVICE_DELIVERY_DEFAULT_SALT, PendingRegistrationResponse,
|
||||
PushServiceDeliveryConfigResponse, 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,
|
||||
))
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -138,6 +156,13 @@ pub fn instance_config_page(
|
||||
(integrations_config_section(base, csrf_token, &instance_config.integrations))
|
||||
},
|
||||
))
|
||||
(config_group(
|
||||
"Push notifications",
|
||||
"Consent for the relay that delivers official mobile app notifications.",
|
||||
html! {
|
||||
(push_relay_section(base, csrf_token, &instance_config.push_relay))
|
||||
},
|
||||
))
|
||||
(config_group(
|
||||
"Media & retention",
|
||||
"Attachment expiry rules that can be changed without editing environment variables.",
|
||||
@@ -150,10 +175,9 @@ 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))
|
||||
(push_service_delivery_section(base, csrf_token, &instance_config.push_service_delivery))
|
||||
(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))
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
@@ -202,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 \
|
||||
@@ -212,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))
|
||||
}
|
||||
},
|
||||
@@ -356,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" }
|
||||
@@ -364,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"))
|
||||
@@ -986,334 +1022,69 @@ fn gateway_rollout_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn voice_noise_suppression_section(
|
||||
fn push_relay_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
|
||||
push_relay: &PushRelayConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if voice_noise_suppression.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
let status = if push_relay.relay_consent_accepted {
|
||||
("Accepted", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
("Not accepted", 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");
|
||||
let accepted_at =
|
||||
format_optional_admin_timestamp(push_relay.relay_consent_accepted_at.as_deref(), "Never");
|
||||
let accepted_by = push_relay
|
||||
.relay_consent_accepted_by
|
||||
.as_deref()
|
||||
.unwrap_or("Nobody");
|
||||
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.",
|
||||
"Push Relay",
|
||||
"Official mobile app notifications travel through Fluxer's relay to Apple and Google. \
|
||||
The relay delivers them only after an operator accepts its privacy notice.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_voice_noise_suppression"} {
|
||||
form method="post" action={(base) "/instance-config?action=update_push_relay"} {
|
||||
(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" }
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Relay consent" }
|
||||
(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" {
|
||||
(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_service_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
push_service_delivery: &PushServiceDeliveryConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if push_service_delivery.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = push_service_delivery.included_user_ids.join("\n");
|
||||
let excluded_user_ids = push_service_delivery.excluded_user_ids.join("\n");
|
||||
let relay_consent_stamp = match (
|
||||
push_service_delivery.relay_consent_accepted_at.as_deref(),
|
||||
push_service_delivery.relay_consent_accepted_by.as_deref(),
|
||||
) {
|
||||
(Some(at), Some(by)) => Some(format!("Accepted {at} by user {by}")),
|
||||
(Some(at), None) => Some(format!("Accepted {at}")),
|
||||
_ => None,
|
||||
};
|
||||
section_card_with_description(
|
||||
"Push Service Delivery",
|
||||
"Routes push notification delivery for the selected accounts through the push service. \
|
||||
Accounts the rollout does not select keep the current path.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_push_service_delivery"} {
|
||||
(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 " (push_service_delivery.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"push_service_delivery_enabled",
|
||||
"true",
|
||||
"Hand push notifications to the push service",
|
||||
push_service_delivery.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state. With this unchecked every notification keeps the \
|
||||
current delivery path, so the rollout and targeting fields below have no \
|
||||
effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Managed relay consent" }
|
||||
(checkbox(
|
||||
"push_service_delivery_relay_consent_accepted",
|
||||
"push_relay_consent_accepted",
|
||||
"true",
|
||||
"Accept the push relay supplemental privacy notice",
|
||||
push_service_delivery.relay_consent_accepted,
|
||||
push_relay.relay_consent_accepted,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Required only for the official mobile apps, whose notifications travel \
|
||||
through Fluxer's relay to Apple and Google. Until this is accepted those \
|
||||
notifications are dropped. Self-hosted UnifiedPush and ntfy endpoints \
|
||||
never reach the relay and are unaffected. "
|
||||
"Until this is accepted official mobile app notifications are dropped. \
|
||||
Self-hosted UnifiedPush and ntfy endpoints never reach the relay and are \
|
||||
unaffected. "
|
||||
a href="https://fluxer.com/push-relay" target="_blank" rel="noreferrer"
|
||||
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
|
||||
"Read the notice"
|
||||
}
|
||||
}
|
||||
@if let Some(stamp) = relay_consent_stamp {
|
||||
p class="text-xs text-neutral-500" { (stamp) }
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"push_service_delivery_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&push_service_delivery.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(
|
||||
"push_service_delivery_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&push_service_delivery.rollout_salt,
|
||||
PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(form_field_group("Accepted at", "push_relay_consent_accepted_at", false, None, None,
|
||||
html! {
|
||||
input type="text" id="push_relay_consent_accepted_at"
|
||||
value=(accepted_at)
|
||||
disabled class=(FORM_INPUT_CLASS);
|
||||
},
|
||||
))
|
||||
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(
|
||||
"push_service_delivery_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
(form_field_group("Accepted by user ID", "push_relay_consent_accepted_by", false, None, None,
|
||||
html! {
|
||||
input type="text" id="push_relay_consent_accepted_by"
|
||||
value=(accepted_by)
|
||||
disabled class=(FORM_INPUT_CLASS);
|
||||
},
|
||||
))
|
||||
(entry_count_hint(
|
||||
push_service_delivery.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" {
|
||||
(textarea_input(
|
||||
"push_service_delivery_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
push_service_delivery.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."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Push Service Delivery Configuration"))
|
||||
(submit_button("Save Push Relay Settings"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1425,6 +1196,38 @@ fn domain_migration_section(
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(checkbox(
|
||||
"domain_migration_include_premium_users",
|
||||
"true",
|
||||
"Include premium users",
|
||||
domain_migration.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(
|
||||
"domain_migration_included_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1500000000000000005\n1500000000000000006",
|
||||
&domain_migration.included_guild_ids.join("\n"),
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
domain_migration.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(
|
||||
"domain_migration_excluded_user_ids",
|
||||
@@ -1546,6 +1349,38 @@ fn altcha_captcha_section(
|
||||
regardless of the percentage above. Invalid entries prevent the save."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(checkbox(
|
||||
"altcha_captcha_include_premium_users",
|
||||
"true",
|
||||
"Include premium users",
|
||||
altcha_captcha.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(
|
||||
"altcha_captcha_included_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1500000000000000005\n1500000000000000006",
|
||||
&altcha_captcha.included_guild_ids.join("\n"),
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
altcha_captcha.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(
|
||||
"altcha_captcha_excluded_user_ids",
|
||||
@@ -1593,6 +1428,143 @@ fn altcha_captcha_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn profile_timezone_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
profile_timezone: &ProfileTimezoneConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if profile_timezone.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = profile_timezone.included_user_ids.join("\n");
|
||||
let excluded_user_ids = profile_timezone.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Profile Timezone",
|
||||
"Lets the selected users save a time zone in profile settings and show their local time \
|
||||
on their profile. Users outside the rollout cannot change it, and a saved time zone \
|
||||
stays hidden from everyone while its owner is outside the rollout.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_profile_timezone"} {
|
||||
(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 " (profile_timezone.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"profile_timezone_enabled",
|
||||
"true",
|
||||
"Serve profile timezone to the selected users",
|
||||
profile_timezone.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state and the kill switch. With this unchecked nobody \
|
||||
sees the setting and every saved time zone is hidden."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"profile_timezone_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&profile_timezone.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into profile timezone, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"profile_timezone_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&profile_timezone.rollout_salt,
|
||||
PROFILE_TIMEZONE_DEFAULT_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"profile_timezone_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
profile_timezone.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users get profile \
|
||||
timezone regardless of the percentage above. Invalid entries prevent the save."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(checkbox(
|
||||
"profile_timezone_include_premium_users",
|
||||
"true",
|
||||
"Include premium users",
|
||||
profile_timezone.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(
|
||||
"profile_timezone_included_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1500000000000000005\n1500000000000000006",
|
||||
&profile_timezone.included_guild_ids.join("\n"),
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
profile_timezone.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(
|
||||
"profile_timezone_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
profile_timezone.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."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Profile Timezone Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn experiment_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
@@ -2185,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."
|
||||
};
|
||||
@@ -2207,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() {
|
||||
@@ -2259,37 +2203,49 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_service_delivery_section_shows_the_relay_consent_toggle() {
|
||||
let accepted = PushServiceDeliveryConfigResponse {
|
||||
fn push_relay_section_shows_the_consent_toggle() {
|
||||
let accepted = PushRelayConfigResponse {
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: Some("2026-09-27T10:11:12.000Z".to_owned()),
|
||||
relay_consent_accepted_by: Some("1130650140672000000".to_owned()),
|
||||
..PushServiceDeliveryConfigResponse::default()
|
||||
};
|
||||
let markup = push_service_delivery_section("/admin", "csrf", &accepted).into_string();
|
||||
assert!(markup.contains("name=\"push_service_delivery_relay_consent_accepted\""));
|
||||
let markup = push_relay_section("/admin", "csrf", &accepted).into_string();
|
||||
assert!(markup.contains("action=update_push_relay"));
|
||||
assert!(markup.contains("name=\"push_relay_consent_accepted\""));
|
||||
assert!(markup.contains("https://fluxer.com/push-relay"));
|
||||
assert!(markup.contains("Accepted 2026-09-27T10:11:12.000Z by user 1130650140672000000"));
|
||||
assert!(markup.contains("value=\"Sep 27, 2026, 10:11 AM UTC\""));
|
||||
assert!(markup.contains("value=\"1130650140672000000\""));
|
||||
assert!(!markup.contains("name=\"push_relay_consent_accepted_at\""));
|
||||
assert!(!markup.contains("name=\"push_relay_consent_accepted_by\""));
|
||||
assert!(!markup.to_lowercase().contains("rollout"));
|
||||
|
||||
let unaccepted = push_service_delivery_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&PushServiceDeliveryConfigResponse::default(),
|
||||
)
|
||||
.into_string();
|
||||
assert!(unaccepted.contains("name=\"push_service_delivery_relay_consent_accepted\""));
|
||||
assert!(!unaccepted.contains("Accepted "));
|
||||
let unaccepted =
|
||||
push_relay_section("/admin", "csrf", &PushRelayConfigResponse::default()).into_string();
|
||||
assert!(unaccepted.contains("name=\"push_relay_consent_accepted\""));
|
||||
assert!(unaccepted.contains("Not accepted"));
|
||||
assert!(unaccepted.contains("value=\"Never\""));
|
||||
assert!(unaccepted.contains("value=\"Nobody\""));
|
||||
}
|
||||
|
||||
#[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,30 +392,7 @@ 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": [],
|
||||
"suppression_strength": 80,
|
||||
"future_presentation_knob": "verbose",
|
||||
"future_knob": 7,
|
||||
"future_object_knob": {"nested": true},
|
||||
"future_list_knob": ["a", "b"]
|
||||
},
|
||||
"push_service_delivery": {
|
||||
"enabled": true,
|
||||
"config_version": 3,
|
||||
"rollout_basis_points": 5000,
|
||||
"rollout_salt": "push-service-delivery-v1",
|
||||
"included_user_ids": ["1500000000000000002"],
|
||||
"excluded_user_ids": [],
|
||||
"push_relay": {
|
||||
"relay_consent_accepted": true,
|
||||
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
|
||||
"relay_consent_accepted_by": "1130650140672000000"
|
||||
@@ -427,7 +404,13 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"rollout_salt": "domain-migration-v1",
|
||||
"included_user_ids": ["1500000000000000001"],
|
||||
"excluded_user_ids": [],
|
||||
"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
|
||||
},
|
||||
@@ -441,8 +424,21 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"anonymous_enabled": true,
|
||||
"cost": 5000,
|
||||
"max_counter": 10000,
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"future_altcha_knob": "argon2id"
|
||||
},
|
||||
"profile_timezone": {
|
||||
"enabled": true,
|
||||
"config_version": 2,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "profile-timezone-v1",
|
||||
"included_user_ids": ["1500000000000000001"],
|
||||
"excluded_user_ids": [],
|
||||
"included_guild_ids": ["1500000000000000005"],
|
||||
"include_premium_users": true,
|
||||
"future_profile_timezone_knob": true
|
||||
},
|
||||
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
|
||||
"registration": {
|
||||
"mode": "open",
|
||||
@@ -460,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": {
|
||||
@@ -558,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
|
||||
}"##;
|
||||
@@ -567,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);
|
||||
@@ -579,18 +602,51 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
assert_eq!(resp.domain_migration.included_user_ids.len(), 1);
|
||||
assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100);
|
||||
assert!(resp.domain_migration.standalone_forwarding);
|
||||
assert!(resp.push_service_delivery.relay_consent_accepted);
|
||||
assert!(resp.push_relay.relay_consent_accepted);
|
||||
assert!(resp.altcha_captcha.enabled);
|
||||
assert_eq!(resp.altcha_captcha.config_version, 3);
|
||||
assert!(resp.altcha_captcha.anonymous_enabled);
|
||||
assert_eq!(resp.altcha_captcha.excluded_user_ids.len(), 1);
|
||||
assert_eq!(resp.altcha_captcha.max_counter, 10000);
|
||||
assert!(resp.profile_timezone.enabled);
|
||||
assert_eq!(resp.profile_timezone.config_version, 2);
|
||||
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_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\",", "")
|
||||
@@ -604,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},",
|
||||
"",
|
||||
@@ -618,15 +675,9 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_push_service_delivery_relay_consent() {
|
||||
let accepted: types::PushServiceDeliveryConfigResponse = serde_json::from_str(
|
||||
fn deserialize_push_relay_config() {
|
||||
let accepted: types::PushRelayConfigResponse = serde_json::from_str(
|
||||
r#"{
|
||||
"enabled": true,
|
||||
"config_version": 3,
|
||||
"rollout_basis_points": 5000,
|
||||
"rollout_salt": "push-service-delivery-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"relay_consent_accepted": true,
|
||||
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
|
||||
"relay_consent_accepted_by": "1130650140672000000"
|
||||
@@ -644,37 +695,23 @@ fn deserialize_push_service_delivery_relay_consent() {
|
||||
Some("1130650140672000000")
|
||||
);
|
||||
|
||||
let legacy: types::PushServiceDeliveryConfigResponse = serde_json::from_str(
|
||||
r#"{
|
||||
"enabled": true,
|
||||
"config_version": 3,
|
||||
"rollout_basis_points": 5000,
|
||||
"rollout_salt": "push-service-delivery-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
}"#,
|
||||
)
|
||||
.expect("a response written before relay consent must still deserialize");
|
||||
let empty: types::PushRelayConfigResponse =
|
||||
serde_json::from_str("{}").expect("an empty push relay config must deserialize");
|
||||
|
||||
assert!(!legacy.relay_consent_accepted);
|
||||
assert!(legacy.relay_consent_accepted_at.is_none());
|
||||
assert!(legacy.relay_consent_accepted_by.is_none());
|
||||
assert!(!empty.relay_consent_accepted);
|
||||
assert!(empty.relay_consent_accepted_at.is_none());
|
||||
assert!(empty.relay_consent_accepted_by.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_push_service_delivery_update_omits_an_unset_relay_consent() {
|
||||
let without = types::PushServiceDeliveryConfigUpdateRequest {
|
||||
enabled: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
fn serialize_push_relay_update_omits_an_unset_consent() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(&without).unwrap(),
|
||||
serde_json::json!({"enabled": true})
|
||||
serde_json::to_value(types::PushRelayConfigUpdateRequest::default()).unwrap(),
|
||||
serde_json::json!({})
|
||||
);
|
||||
|
||||
let with = types::PushServiceDeliveryConfigUpdateRequest {
|
||||
let with = types::PushRelayConfigUpdateRequest {
|
||||
relay_consent_accepted: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&with).unwrap(),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -16,12 +16,13 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {
|
||||
getGatewayRolloutConfigPublisher,
|
||||
getInstanceConfigRepository,
|
||||
getPushServiceDeliveryConfigPublisher,
|
||||
getPushRelayConfigPublisher,
|
||||
} from '@app/api/middleware/ServiceSingletons';
|
||||
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,
|
||||
@@ -37,12 +38,8 @@ import {
|
||||
import {AltchaCaptchaConfigSchema} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {
|
||||
type PushServiceDeliveryConfig,
|
||||
PushServiceDeliveryConfigSchema,
|
||||
type PushServiceDeliveryConfigUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {ProfileTimezoneConfigSchema} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
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';
|
||||
@@ -69,10 +66,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
const [
|
||||
ssoConfig,
|
||||
gatewayRollout,
|
||||
voiceNoiseSuppression,
|
||||
pushServiceDelivery,
|
||||
pushRelay,
|
||||
domainMigration,
|
||||
altchaCaptcha,
|
||||
profileTimezone,
|
||||
experimentDelivery,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
@@ -80,21 +77,22 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
] = await Promise.all([
|
||||
instanceConfigRepository.getSsoConfig(),
|
||||
instanceConfigRepository.getGatewayRolloutConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getPushServiceDeliveryConfig(),
|
||||
instanceConfigRepository.getPushRelayConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getAltchaCaptchaConfig(),
|
||||
instanceConfigRepository.getProfileTimezoneConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
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: {
|
||||
@@ -114,10 +112,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
redirect_uri: deriveSsoRedirectUri(Config.endpoints.webApp),
|
||||
},
|
||||
gateway_rollout: gatewayRollout,
|
||||
voice_noise_suppression: voiceNoiseSuppression,
|
||||
push_service_delivery: pushServiceDelivery,
|
||||
push_relay: pushRelay,
|
||||
domain_migration: domainMigration,
|
||||
altcha_captcha: altchaCaptcha,
|
||||
profile_timezone: profileTimezone,
|
||||
experiment_delivery: experimentDelivery,
|
||||
registration: {
|
||||
...registrationConfig,
|
||||
@@ -151,6 +149,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
},
|
||||
integrations,
|
||||
media,
|
||||
billing,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,10 +202,10 @@ async function grantSetupCompleterAdminACL(ctx: Context<HonoEnv>): Promise<boole
|
||||
}
|
||||
|
||||
function relayConsentStamp(
|
||||
current: PushServiceDeliveryConfig,
|
||||
patch: Partial<PushServiceDeliveryConfigUpdateRequest>,
|
||||
current: PushRelayConfig,
|
||||
patch: PushRelayConfigUpdateRequest,
|
||||
adminUserId: string,
|
||||
): Partial<PushServiceDeliveryConfig> {
|
||||
): Partial<PushRelayConfig> {
|
||||
const accepted = patch.relay_consent_accepted;
|
||||
if (accepted === undefined || accepted === current.relay_consent_accepted) {
|
||||
return {};
|
||||
@@ -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,31 +373,16 @@ 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_service_delivery) {
|
||||
const patch = omitUndefinedFields(data.push_service_delivery);
|
||||
if (data.push_relay) {
|
||||
const patch = omitUndefinedFields(data.push_relay);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const adminUserId = ctx.get('adminUserId').toString();
|
||||
const landed = await instanceConfigRepository.updatePushServiceDeliveryConfig((current) =>
|
||||
PushServiceDeliveryConfigSchema.parse({
|
||||
...current,
|
||||
...patch,
|
||||
...relayConsentStamp(current, patch, adminUserId),
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
await getPushServiceDeliveryConfigPublisher().publish(landed);
|
||||
const landed = await instanceConfigRepository.updatePushRelayConfig((current) => ({
|
||||
...current,
|
||||
...patch,
|
||||
...relayConsentStamp(current, patch, adminUserId),
|
||||
}));
|
||||
await getPushRelayConfigPublisher().publish(landed);
|
||||
}
|
||||
}
|
||||
if (data.domain_migration) {
|
||||
@@ -334,6 +409,18 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.profile_timezone) {
|
||||
const patch = omitUndefinedFields(data.profile_timezone);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await instanceConfigRepository.updateProfileTimezoneConfig((current) =>
|
||||
ProfileTimezoneConfigSchema.parse({
|
||||
...current,
|
||||
...patch,
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.experiment_delivery) {
|
||||
const patch = data.experiment_delivery;
|
||||
await instanceConfigRepository.updateExperimentDeliveryConfig((current) =>
|
||||
@@ -403,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
|
||||
@@ -495,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({
|
||||
@@ -691,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();
|
||||
@@ -698,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, {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
|
||||
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
|
||||
import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
|
||||
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
|
||||
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
|
||||
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
@@ -16,12 +16,12 @@ 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 {
|
||||
DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
|
||||
type PushServiceDeliveryConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
type LegacyPushServiceDeliveryWire,
|
||||
toLegacyPushServiceDeliveryWire,
|
||||
} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const PUSH_SERVICE_DELIVERY_CONFIG_KEY = 'push_service_delivery_config';
|
||||
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
|
||||
|
||||
describe('instance config admin PATCH under concurrent writes', () => {
|
||||
let harness: ApiTestHarness;
|
||||
@@ -55,13 +55,13 @@ describe('instance config admin PATCH under concurrent writes', () => {
|
||||
const patchConfig = (admin: TestAccount, body: Record<string, unknown>) =>
|
||||
createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
|
||||
|
||||
const spyOnPushDeliveryPublishes = () =>
|
||||
vi.spyOn(PushServiceDeliveryConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
|
||||
const spyOnPushRelayPublishes = () =>
|
||||
vi.spyOn(PushRelayConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
|
||||
|
||||
async function readStoredPushServiceDelivery(): Promise<PushServiceDeliveryConfig> {
|
||||
const raw = await executor.readDirectly(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
|
||||
if (raw === null) throw new Error('push service delivery config was never stored');
|
||||
return JSON.parse(raw) as PushServiceDeliveryConfig;
|
||||
async function readStoredPushRelay(): Promise<LegacyPushServiceDeliveryWire> {
|
||||
const raw = await executor.readDirectly(PUSH_RELAY_CONFIG_KEY);
|
||||
if (raw === null) throw new Error('push relay config was never stored');
|
||||
return JSON.parse(raw) as LegacyPushServiceDeliveryWire;
|
||||
}
|
||||
|
||||
async function listConfigUpdateAudits(): Promise<Array<AdminAuditLog>> {
|
||||
@@ -84,37 +84,32 @@ describe('instance config admin PATCH under concurrent writes', () => {
|
||||
});
|
||||
|
||||
it('answers with a conflict and neither writes, publishes nor audits once every attempt has lost the race', async () => {
|
||||
const publish = spyOnPushDeliveryPublishes();
|
||||
const publish = spyOnPushRelayPublishes();
|
||||
const admin = await createAdmin();
|
||||
await patchConfig(admin, {push_service_delivery: {enabled: true, rollout_basis_points: 1000}}).execute();
|
||||
await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
|
||||
publish.mockClear();
|
||||
const auditsBefore = await listConfigUpdateAudits();
|
||||
executor.watch(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
|
||||
executor.watch(PUSH_RELAY_CONFIG_KEY);
|
||||
const unaccepted = {
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
};
|
||||
let competingWrites = 0;
|
||||
executor.competeBeforeEachWrite(async () => {
|
||||
competingWrites++;
|
||||
await executor.writeDirectly(
|
||||
PUSH_SERVICE_DELIVERY_CONFIG_KEY,
|
||||
JSON.stringify({
|
||||
...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
|
||||
enabled: false,
|
||||
rollout_basis_points: 1000,
|
||||
config_version: 100 + competingWrites,
|
||||
}),
|
||||
PUSH_RELAY_CONFIG_KEY,
|
||||
JSON.stringify(toLegacyPushServiceDeliveryWire(unaccepted, 100 + competingWrites)),
|
||||
);
|
||||
});
|
||||
|
||||
await patchConfig(admin, {push_service_delivery: {rollout_basis_points: 5000}})
|
||||
await patchConfig(admin, {push_relay: {relay_consent_accepted: true}})
|
||||
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.CONFLICT)
|
||||
.execute();
|
||||
|
||||
expect(executor.events).not.toContain('write');
|
||||
expect(await readStoredPushServiceDelivery()).toEqual({
|
||||
...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
|
||||
enabled: false,
|
||||
rollout_basis_points: 1000,
|
||||
config_version: 100 + competingWrites,
|
||||
});
|
||||
expect(await readStoredPushRelay()).toEqual(toLegacyPushServiceDeliveryWire(unaccepted, 100 + competingWrites));
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
expect(await listConfigUpdateAudits()).toHaveLength(auditsBefore.length);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -2,24 +2,54 @@
|
||||
|
||||
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
|
||||
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
|
||||
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
|
||||
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {LegacyPushServiceDeliveryWire} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
|
||||
const ACCEPTED_AT = '2026-09-20T08:00:00.000Z';
|
||||
const ACCEPTED_BY = '1500000000000000007';
|
||||
|
||||
const PROD_ROW = {
|
||||
enabled: true,
|
||||
config_version: 41,
|
||||
rollout_basis_points: 10000,
|
||||
rollout_salt: 'push-service-delivery-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: ACCEPTED_AT,
|
||||
relay_consent_accepted_by: ACCEPTED_BY,
|
||||
};
|
||||
|
||||
interface PushServiceDeliveryRpcResponse {
|
||||
type: 'get_push_service_delivery_config';
|
||||
data: {config: LegacyPushServiceDeliveryWire};
|
||||
}
|
||||
|
||||
describe('push relay supplemental notice consent', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let executor: InstanceConfigWriteRaceExecutor;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
executor = new InstanceConfigWriteRaceExecutor(new InMemoryCassandraQueryExecutor());
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
vi.spyOn(PushServiceDeliveryConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
|
||||
vi.spyOn(PushRelayConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -43,63 +73,88 @@ describe('push relay supplemental notice consent', () => {
|
||||
const readConfig = (admin: TestAccount) =>
|
||||
createBuilder<InstanceConfigResponse>(harness, admin.token).get('/admin/instance/config');
|
||||
|
||||
const readRpcConfig = async (): Promise<LegacyPushServiceDeliveryWire> => {
|
||||
const response = await createBuilder<PushServiceDeliveryRpcResponse>(harness, '')
|
||||
.post('/test/rpc-session-init')
|
||||
.body({type: 'get_push_service_delivery_config'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(response.type).toBe('get_push_service_delivery_config');
|
||||
return response.data.config;
|
||||
};
|
||||
|
||||
async function storeRow(row: Record<string, unknown>): Promise<void> {
|
||||
await executor.writeDirectly(PUSH_RELAY_CONFIG_KEY, JSON.stringify(row));
|
||||
getInstanceConfigRepository().clearCacheForTesting();
|
||||
}
|
||||
|
||||
async function readStoredRow(): Promise<unknown> {
|
||||
const raw = await executor.readDirectly(PUSH_RELAY_CONFIG_KEY);
|
||||
if (raw === null) throw new Error('push relay config was never stored');
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
it('reads back as unaccepted before an operator agrees', async () => {
|
||||
const admin = await createAdmin();
|
||||
|
||||
const config = await readConfig(admin).execute();
|
||||
|
||||
expect(config.push_service_delivery).toMatchObject({
|
||||
expect(config.push_relay).toEqual({
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the consent of a stored push service delivery row', async () => {
|
||||
const admin = await createAdmin();
|
||||
await storeRow(PROD_ROW);
|
||||
|
||||
const config = await readConfig(admin).execute();
|
||||
|
||||
expect(config.push_relay).toEqual({
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: ACCEPTED_AT,
|
||||
relay_consent_accepted_by: ACCEPTED_BY,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a stored row without consent fields as unaccepted', async () => {
|
||||
const admin = await createAdmin();
|
||||
await storeRow({enabled: true, config_version: 3, rollout_basis_points: 10000});
|
||||
|
||||
const config = await readConfig(admin).execute();
|
||||
|
||||
expect(config.push_relay.relay_consent_accepted).toBe(false);
|
||||
expect(await readRpcConfig()).toMatchObject({config_version: 3, relay_consent_accepted: false});
|
||||
});
|
||||
|
||||
it('stamps the acting admin and the acceptance time when consent is given', async () => {
|
||||
const admin = await createAdmin();
|
||||
|
||||
const updated = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(updated.push_service_delivery.relay_consent_accepted).toBe(true);
|
||||
expect(updated.push_service_delivery.relay_consent_accepted_by).toBe(admin.userId);
|
||||
expect(Date.parse(updated.push_service_delivery.relay_consent_accepted_at ?? '')).not.toBeNaN();
|
||||
});
|
||||
|
||||
it('keeps the first acceptance stamp when a later patch changes only the rollout', async () => {
|
||||
const admin = await createAdmin();
|
||||
const accepted = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
const rolledOut = await patchConfig(admin, {
|
||||
push_service_delivery: {enabled: true, rollout_basis_points: 2500},
|
||||
}).execute();
|
||||
|
||||
expect(rolledOut.push_service_delivery).toMatchObject({
|
||||
enabled: true,
|
||||
rollout_basis_points: 2500,
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: accepted.push_service_delivery.relay_consent_accepted_at,
|
||||
relay_consent_accepted_by: admin.userId,
|
||||
});
|
||||
expect(updated.push_relay.relay_consent_accepted).toBe(true);
|
||||
expect(updated.push_relay.relay_consent_accepted_by).toBe(admin.userId);
|
||||
expect(Date.parse(updated.push_relay.relay_consent_accepted_at ?? '')).not.toBeNaN();
|
||||
});
|
||||
|
||||
it('keeps the stamp untouched when consent is re-sent unchanged', async () => {
|
||||
const admin = await createAdmin();
|
||||
const accepted = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
const accepted = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
const resent = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
const resent = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(resent.push_service_delivery.relay_consent_accepted_at).toBe(
|
||||
accepted.push_service_delivery.relay_consent_accepted_at,
|
||||
);
|
||||
expect(resent.push_relay).toEqual(accepted.push_relay);
|
||||
});
|
||||
|
||||
it('clears the stamp when an operator withdraws consent', async () => {
|
||||
const admin = await createAdmin();
|
||||
await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
const withdrawn = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: false}}).execute();
|
||||
const withdrawn = await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
|
||||
|
||||
expect(withdrawn.push_service_delivery).toMatchObject({
|
||||
expect(withdrawn.push_relay).toEqual({
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
@@ -110,23 +165,146 @@ describe('push relay supplemental notice consent', () => {
|
||||
const admin = await createAdmin();
|
||||
|
||||
const updated = await patchConfig(admin, {
|
||||
push_service_delivery: {
|
||||
push_relay: {
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: '2020-01-01T00:00:00.000Z',
|
||||
relay_consent_accepted_by: '1500000000000000009',
|
||||
},
|
||||
}).execute();
|
||||
|
||||
expect(updated.push_service_delivery.relay_consent_accepted_at).not.toBe('2020-01-01T00:00:00.000Z');
|
||||
expect(updated.push_service_delivery.relay_consent_accepted_by).toBe(admin.userId);
|
||||
expect(updated.push_relay.relay_consent_accepted_at).not.toBe('2020-01-01T00:00:00.000Z');
|
||||
expect(updated.push_relay.relay_consent_accepted_by).toBe(admin.userId);
|
||||
});
|
||||
|
||||
it('publishes the consent to the delivery services', async () => {
|
||||
it('writes the full legacy document and bumps the stored config version', async () => {
|
||||
const admin = await createAdmin();
|
||||
const publish = vi.mocked(PushServiceDeliveryConfigPublisher.prototype.publish);
|
||||
await storeRow({
|
||||
...PROD_ROW,
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
});
|
||||
|
||||
await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(publish).toHaveBeenCalledWith(expect.objectContaining({relay_consent_accepted: true}));
|
||||
expect(await readStoredRow()).toEqual({
|
||||
enabled: true,
|
||||
config_version: 42,
|
||||
rollout_basis_points: 10000,
|
||||
rollout_salt: 'push-service-delivery-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: updated.push_relay.relay_consent_accepted_at,
|
||||
relay_consent_accepted_by: admin.userId,
|
||||
});
|
||||
|
||||
await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
|
||||
|
||||
expect(await readStoredRow()).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 43,
|
||||
rollout_basis_points: 10000,
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rewrites a partially enrolled stored row as full enrolment', async () => {
|
||||
const admin = await createAdmin();
|
||||
await storeRow({
|
||||
...PROD_ROW,
|
||||
enabled: false,
|
||||
rollout_basis_points: 250,
|
||||
rollout_salt: 'custom-salt',
|
||||
included_user_ids: ['1500000000000000003'],
|
||||
excluded_user_ids: ['1500000000000000004'],
|
||||
});
|
||||
|
||||
await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
|
||||
|
||||
expect(await readStoredRow()).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 42,
|
||||
rollout_basis_points: 10000,
|
||||
rollout_salt: 'push-service-delivery-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes the legacy delivery document with the consent', async () => {
|
||||
const admin = await createAdmin();
|
||||
const publish = vi.mocked(PushRelayConfigPublisher.prototype.publish);
|
||||
|
||||
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(publish).toHaveBeenCalledTimes(1);
|
||||
expect(publish).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
rollout_basis_points: 10000,
|
||||
rollout_salt: 'push-service-delivery-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: updated.push_relay.relay_consent_accepted_at,
|
||||
relay_consent_accepted_by: admin.userId,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write or publish for an empty push relay patch', async () => {
|
||||
const admin = await createAdmin();
|
||||
const publish = vi.mocked(PushRelayConfigPublisher.prototype.publish);
|
||||
|
||||
await patchConfig(admin, {push_relay: {}}).execute();
|
||||
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
expect(await executor.readDirectly(PUSH_RELAY_CONFIG_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores the retired push_service_delivery section', async () => {
|
||||
const admin = await createAdmin();
|
||||
const publish = vi.mocked(PushRelayConfigPublisher.prototype.publish);
|
||||
|
||||
const updated = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(updated.push_relay.relay_consent_accepted).toBe(false);
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers the legacy delivery RPC with full enrolment and the stored consent', async () => {
|
||||
await storeRow(PROD_ROW);
|
||||
|
||||
expect(await readRpcConfig()).toEqual(PROD_ROW);
|
||||
});
|
||||
|
||||
it('answers the legacy delivery RPC with defaults before anything is stored', async () => {
|
||||
expect(await readRpcConfig()).toEqual({
|
||||
enabled: true,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 10000,
|
||||
rollout_salt: 'push-service-delivery-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('answers the legacy delivery RPC with consent given through the admin API', async () => {
|
||||
const admin = await createAdmin();
|
||||
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(await readRpcConfig()).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
rollout_basis_points: 10000,
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: updated.push_relay.relay_consent_accepted_at,
|
||||
relay_consent_accepted_by: admin.userId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -43,7 +43,6 @@ async function revokeSessionTargets(
|
||||
scope === 'all'
|
||||
? users.deleteAllPushSubscriptions(userId)
|
||||
: users.deletePushSubscriptionsForAuthSessions(userId, sessionIdHashes, {deleteUnboundSubscriptions: true}),
|
||||
() => gateway.invalidatePushSubscriptions({userId}),
|
||||
];
|
||||
if (scope === 'selected' || targets.length > 0) {
|
||||
steps.push(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
|
||||
import {LoginRequired} from '@app/api/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
@@ -10,7 +11,7 @@ import {entityTagMatches} from '@app/api/utils/EntityTag';
|
||||
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 {resolveVoiceNoiseSuppressionAssignment} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {resolveProfileTimezoneAssignment} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
|
||||
export function ExperimentController(app: HonoApp) {
|
||||
@@ -30,20 +31,26 @@ export function ExperimentController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const [delivery, voiceConfig, domainMigrationConfig, altchaCaptchaConfig] = await Promise.all([
|
||||
const [delivery, domainMigrationConfig, altchaCaptchaConfig, profileTimezoneConfig] = await Promise.all([
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getAltchaCaptchaConfig(),
|
||||
instanceConfigRepository.getProfileTimezoneConfig(),
|
||||
]);
|
||||
const user = ctx.get('user');
|
||||
const userId = user.id.toString();
|
||||
const targeting = await resolveExperimentTargeting(user, [
|
||||
domainMigrationConfig,
|
||||
altchaCaptchaConfig,
|
||||
profileTimezoneConfig,
|
||||
]);
|
||||
const userId = ctx.get('user').id.toString();
|
||||
const body: ExperimentAssignmentsResponse = {
|
||||
poll_interval_seconds: delivery.poll_interval_seconds,
|
||||
poll_jitter_percent: delivery.poll_jitter_percent,
|
||||
assignments: {
|
||||
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId),
|
||||
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId),
|
||||
altcha_captcha: resolveAltchaCaptchaAssignment(altchaCaptchaConfig, userId),
|
||||
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId, targeting),
|
||||
altcha_captcha: resolveAltchaCaptchaAssignment(altchaCaptchaConfig, userId, targeting),
|
||||
profile_timezone: resolveProfileTimezoneAssignment(profileTimezoneConfig, userId, targeting),
|
||||
},
|
||||
};
|
||||
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {ExperimentTargeting} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
|
||||
interface TargetableExperimentConfig {
|
||||
readonly enabled: boolean;
|
||||
readonly included_guild_ids: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
const NO_GUILDS: ReadonlySet<string> = new Set();
|
||||
|
||||
export const ANONYMOUS_EXPERIMENT_TARGETING: ExperimentTargeting = {
|
||||
memberGuildIds: NO_GUILDS,
|
||||
premium: false,
|
||||
};
|
||||
|
||||
export async function resolveExperimentTargeting(
|
||||
user: User,
|
||||
configs: ReadonlyArray<TargetableExperimentConfig>,
|
||||
): Promise<ExperimentTargeting> {
|
||||
const needsGuilds = configs.some((config) => config.enabled && config.included_guild_ids.length > 0);
|
||||
const memberGuildIds = needsGuilds
|
||||
? new Set((await getUserRepository().getUserGuildIds(user.id)).map((guildId) => guildId.toString()))
|
||||
: NO_GUILDS;
|
||||
return {memberGuildIds, premium: !user.isBot && user.isPremium()};
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {grantPremium} from '@app/api/user/tests/UserTestUtils';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {
|
||||
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
|
||||
@@ -15,16 +18,15 @@ import {
|
||||
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {
|
||||
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
INERT_PROFILE_TIMEZONE_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
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';
|
||||
|
||||
@@ -59,40 +61,13 @@ 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -195,61 +170,143 @@ describe('GET /experiments', () => {
|
||||
expect(body.assignments.altcha_captcha).toEqual({enabled: 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 () => {
|
||||
it('resolves the profile timezone caller through the allowlist and the exclusion list', async () => {
|
||||
const targeted = await createTestAccount(harness);
|
||||
const untargeted = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setVoiceNoiseSuppressionConfig({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
const excluded = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 14,
|
||||
default_backend: 'rnnoise',
|
||||
rollout_basis_points: 0,
|
||||
rollout_basis_points: 10000,
|
||||
included_user_ids: [targeted.userId],
|
||||
excluded_user_ids: [excluded.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',
|
||||
});
|
||||
expect(targetedBody.assignments.profile_timezone).toEqual({enabled: true});
|
||||
|
||||
const untargetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, untargeted.token)
|
||||
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(untargetedBody.assignments.voice_noise_suppression).toMatchObject({
|
||||
expect(excludedBody.assignments.profile_timezone).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('bumps the profile timezone 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<{profile_timezone: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({profile_timezone: {enabled: true, included_user_ids: [admin.userId]}})
|
||||
.execute();
|
||||
expect(afterFirst.profile_timezone).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterSecond = await createBuilder<{
|
||||
profile_timezone: {config_version: number; rollout_basis_points: number};
|
||||
}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({profile_timezone: {rollout_basis_points: 2500}})
|
||||
.execute();
|
||||
expect(afterSecond.profile_timezone).toMatchObject({config_version: 2, rollout_basis_points: 2500});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.profile_timezone).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
it('enrols members of an included guild in every experiment and leaves everyone else out', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const outsider = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Experiment Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
const repository = getInstanceConfigRepository();
|
||||
await repository.setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 14,
|
||||
user_targeted: false,
|
||||
backend: null,
|
||||
source: null,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
await repository.setAltchaCaptchaConfig({
|
||||
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
await repository.setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
|
||||
const memberBody = await createBuilder<ExperimentAssignmentsResponse>(harness, member.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(memberBody.assignments.domain_migration).toEqual({enabled: true});
|
||||
expect(memberBody.assignments.altcha_captcha).toEqual({enabled: true});
|
||||
expect(memberBody.assignments.profile_timezone).toEqual({enabled: true});
|
||||
|
||||
const outsiderBody = await createBuilder<ExperimentAssignmentsResponse>(harness, outsider.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(outsiderBody.assignments.domain_migration).toEqual({enabled: false});
|
||||
expect(outsiderBody.assignments.altcha_captcha).toEqual({enabled: false});
|
||||
expect(outsiderBody.assignments.profile_timezone).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('enrols premium users, subscription and lifetime alike, when the switch is on', async () => {
|
||||
const subscriber = await createTestAccount(harness);
|
||||
const visionary = await createTestAccount(harness);
|
||||
const free = await createTestAccount(harness);
|
||||
await grantPremium(harness, subscriber.userId, UserPremiumTypes.SUBSCRIPTION);
|
||||
await grantPremium(harness, visionary.userId, UserPremiumTypes.LIFETIME);
|
||||
await getInstanceConfigRepository().setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
enabled: true,
|
||||
include_premium_users: true,
|
||||
});
|
||||
for (const [account, expected] of [
|
||||
[subscriber, true],
|
||||
[visionary, true],
|
||||
[free, false],
|
||||
] as const) {
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.profile_timezone).toEqual({enabled: expected});
|
||||
}
|
||||
});
|
||||
|
||||
it('stores the guild ids and premium switch an admin sets for each experiment', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
const guildIds = ['1500000000000000001', '1500000000000000002'];
|
||||
const body = await createBuilder<
|
||||
Record<
|
||||
'domain_migration' | 'altcha_captcha' | 'profile_timezone',
|
||||
{included_guild_ids: Array<string>; include_premium_users: boolean}
|
||||
>
|
||||
>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({
|
||||
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.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.domain_migration, body.altcha_captcha, body.profile_timezone]) {
|
||||
expect(section.include_premium_users).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('revalidates with a strong etag and answers 304 when nothing changed', async () => {
|
||||
@@ -281,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);
|
||||
|
||||
@@ -356,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,
|
||||
@@ -430,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) {
|
||||
|
||||
@@ -37,7 +37,6 @@ import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMe
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
const PUSH_BADGE_COUNT_BATCH_SIZE = 100;
|
||||
const USER_PERMISSIONS_BATCH_SIZE = 100;
|
||||
|
||||
const GATEWAY_ERROR_TO_DOMAIN_ERROR: Record<string, () => Error> = {
|
||||
@@ -67,18 +66,6 @@ interface DispatchPresenceParams {
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
interface InvalidatePushBadgeCountParams {
|
||||
userId: UserID;
|
||||
}
|
||||
|
||||
interface InvalidatePushBadgeCountsParams {
|
||||
userIds: Array<UserID>;
|
||||
}
|
||||
|
||||
interface InvalidatePushSubscriptionsParams {
|
||||
userId: UserID;
|
||||
}
|
||||
|
||||
interface ClearPushChannelNotificationsParams {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
@@ -287,8 +274,6 @@ export class GatewayService {
|
||||
private readonly MAX_BATCH_CONCURRENCY = 50;
|
||||
private readonly PENDING_REQUEST_TIMEOUT_MS = ms('30 seconds');
|
||||
private readonly AUTH_CONTEXT_FALLBACK_MS = ms('5 minutes');
|
||||
private readonly BADGE_COUNTS_FALLBACK_MS = ms('5 minutes');
|
||||
private badgeCountsUnsupportedUntil = 0;
|
||||
|
||||
constructor() {
|
||||
this.rpcClient = GatewayRpcClient.getInstance();
|
||||
@@ -704,48 +689,6 @@ export class GatewayService {
|
||||
});
|
||||
}
|
||||
|
||||
async invalidatePushBadgeCount({userId}: InvalidatePushBadgeCountParams): Promise<void> {
|
||||
await this.call('push.invalidate_badge_count', {
|
||||
user_id: userId.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
async invalidatePushBadgeCounts({userIds}: InvalidatePushBadgeCountsParams): Promise<void> {
|
||||
if (Date.now() < this.badgeCountsUnsupportedUntil) {
|
||||
await this.invalidatePushBadgeCountsIndividually(userIds);
|
||||
return;
|
||||
}
|
||||
const batches: Array<Array<UserID>> = [];
|
||||
for (let index = 0; index < userIds.length; index += PUSH_BADGE_COUNT_BATCH_SIZE) {
|
||||
batches.push(userIds.slice(index, index + PUSH_BADGE_COUNT_BATCH_SIZE));
|
||||
}
|
||||
try {
|
||||
await Promise.all(
|
||||
batches.map((batch) =>
|
||||
this.call('push.invalidate_badge_counts', {user_ids: batch.map((userId) => userId.toString())}),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const transformedError = this.transformGatewayError(error);
|
||||
if (!this.isAuthContextUnsupportedError(transformedError)) {
|
||||
throw transformedError;
|
||||
}
|
||||
this.badgeCountsUnsupportedUntil = Date.now() + this.BADGE_COUNTS_FALLBACK_MS;
|
||||
Logger.warn({error}, '[gateway-rpc] push.invalidate_badge_counts unavailable, falling back to per-user calls');
|
||||
await this.invalidatePushBadgeCountsIndividually(userIds);
|
||||
}
|
||||
}
|
||||
|
||||
private async invalidatePushBadgeCountsIndividually(userIds: ReadonlyArray<UserID>): Promise<void> {
|
||||
await Promise.all(userIds.map((userId) => this.invalidatePushBadgeCount({userId})));
|
||||
}
|
||||
|
||||
async invalidatePushSubscriptions({userId}: InvalidatePushSubscriptionsParams): Promise<void> {
|
||||
await this.call('push.invalidate_subscriptions', {
|
||||
user_id: userId.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
async clearPushChannelNotifications({
|
||||
userId,
|
||||
channelId,
|
||||
|
||||
@@ -292,12 +292,6 @@ export abstract class IGatewayService {
|
||||
|
||||
abstract dispatchPresence(params: {userId: UserID; event: GatewayDispatchEvent; data: unknown}): Promise<void>;
|
||||
|
||||
abstract invalidatePushBadgeCount(params: {userId: UserID}): Promise<void>;
|
||||
|
||||
abstract invalidatePushBadgeCounts(params: {userIds: Array<UserID>}): Promise<void>;
|
||||
|
||||
abstract invalidatePushSubscriptions(params: {userId: UserID}): Promise<void>;
|
||||
|
||||
abstract clearPushChannelNotifications(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
|
||||
@@ -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';
|
||||
@@ -41,13 +47,20 @@ import {
|
||||
GatewayRolloutConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {
|
||||
type PushServiceDeliveryConfig,
|
||||
PushServiceDeliveryConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
type BillingCatalogMode,
|
||||
type StoredBillingConfig,
|
||||
StoredBillingConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/InstanceBillingSchemas';
|
||||
import {
|
||||
type VoiceNoiseSuppressionConfig,
|
||||
VoiceNoiseSuppressionConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
type ProfileTimezoneConfig,
|
||||
ProfileTimezoneConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import {
|
||||
type LegacyPushServiceDeliveryWire,
|
||||
type PushRelayConfig,
|
||||
PushRelayConfigSchema,
|
||||
toLegacyPushServiceDeliveryWire,
|
||||
} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import {
|
||||
type ExperimentDeliveryConfig,
|
||||
ExperimentDeliveryConfigSchema,
|
||||
@@ -69,10 +82,10 @@ 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_SERVICE_DELIVERY_CONFIG_KEY = 'push_service_delivery_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';
|
||||
const PROFILE_TIMEZONE_CONFIG_KEY = 'profile_timezone_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const REGISTRATION_CONFIG_KEY = 'registration_config';
|
||||
const REGISTRATION_URLS_KEY = 'registration_urls';
|
||||
@@ -83,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';
|
||||
@@ -109,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 {
|
||||
@@ -349,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: {
|
||||
@@ -361,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,
|
||||
@@ -378,14 +442,15 @@ function getDefaultAppPublicConfig(): InstanceAppPublicConfig {
|
||||
type StoredConfigSection =
|
||||
| 'app public'
|
||||
| 'gateway rollout'
|
||||
| 'voice noise suppression'
|
||||
| 'push service delivery'
|
||||
| 'push relay'
|
||||
| 'domain migration'
|
||||
| 'altcha captcha'
|
||||
| 'profile timezone'
|
||||
| 'experiment delivery'
|
||||
| 'instance policy'
|
||||
| 'integrations'
|
||||
| 'media'
|
||||
| 'billing'
|
||||
| 'registration'
|
||||
| 'registration URLs'
|
||||
| 'pending registrations'
|
||||
@@ -516,12 +581,25 @@ 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),
|
||||
});
|
||||
|
||||
function parseStoredPushRelayConfig(raw: string | null): LegacyPushServiceDeliveryWire {
|
||||
const {config_version, ...config} = salvageStoredConfig(
|
||||
StoredPushRelayConfigSchema,
|
||||
readStoredConfigValue(raw, 'push relay'),
|
||||
'push relay',
|
||||
);
|
||||
return toLegacyPushServiceDeliveryWire(config, config_version);
|
||||
}
|
||||
|
||||
function parseStoredPushServiceDeliveryConfig(raw: string | null): PushServiceDeliveryConfig {
|
||||
return parseStoredConfigOrDefault(PushServiceDeliveryConfigSchema, raw, 'push service delivery');
|
||||
function toPushRelayConfig(wire: LegacyPushServiceDeliveryWire): PushRelayConfig {
|
||||
return {
|
||||
relay_consent_accepted: wire.relay_consent_accepted,
|
||||
relay_consent_accepted_at: wire.relay_consent_accepted_at,
|
||||
relay_consent_accepted_by: wire.relay_consent_accepted_by,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStoredDomainMigrationConfig(raw: string | null): DomainMigrationConfig {
|
||||
@@ -532,6 +610,10 @@ function parseStoredAltchaCaptchaConfig(raw: string | null): AltchaCaptchaConfig
|
||||
return parseStoredConfigOrDefault(AltchaCaptchaConfigSchema, raw, 'altcha captcha');
|
||||
}
|
||||
|
||||
function parseStoredProfileTimezoneConfig(raw: string | null): ProfileTimezoneConfig {
|
||||
return parseStoredConfigOrDefault(ProfileTimezoneConfigSchema, raw, 'profile timezone');
|
||||
}
|
||||
|
||||
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
|
||||
return parseStoredConfigOrDefault(ExperimentDeliveryConfigSchema, raw, 'experiment delivery');
|
||||
}
|
||||
@@ -548,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(),
|
||||
@@ -560,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;
|
||||
@@ -581,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,
|
||||
@@ -704,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;
|
||||
}
|
||||
@@ -1178,10 +1286,10 @@ export class InstanceConfigRepository {
|
||||
checkStoredConfig('gateway rollout', () =>
|
||||
parseStoredGatewayRolloutConfig(snapshot.get(GATEWAY_ROLLOUT_CONFIG_KEY) ?? null),
|
||||
);
|
||||
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
|
||||
parseStoredPushServiceDeliveryConfig(snapshot.get(PUSH_SERVICE_DELIVERY_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);
|
||||
parseStoredProfileTimezoneConfig(snapshot.get(PROFILE_TIMEZONE_CONFIG_KEY) ?? null);
|
||||
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
|
||||
const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
|
||||
checkStoredConfig('registration', () =>
|
||||
@@ -1209,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);
|
||||
@@ -1257,42 +1366,21 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async getVoiceNoiseSuppressionConfig(): Promise<VoiceNoiseSuppressionConfig> {
|
||||
const raw = await this.getConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY);
|
||||
return parseStoredVoiceNoiseSuppressionConfig(raw);
|
||||
async getLegacyPushServiceDeliveryWire(): Promise<LegacyPushServiceDeliveryWire> {
|
||||
const raw = await this.getConfig(PUSH_RELAY_CONFIG_KEY);
|
||||
return parseStoredPushRelayConfig(raw);
|
||||
}
|
||||
|
||||
async setVoiceNoiseSuppressionConfig(config: VoiceNoiseSuppressionConfig): Promise<void> {
|
||||
await this.updateVoiceNoiseSuppressionConfig(() => config);
|
||||
async getPushRelayConfig(): Promise<PushRelayConfig> {
|
||||
return toPushRelayConfig(await this.getLegacyPushServiceDeliveryWire());
|
||||
}
|
||||
|
||||
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 getPushServiceDeliveryConfig(): Promise<PushServiceDeliveryConfig> {
|
||||
const raw = await this.getConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
|
||||
return parseStoredPushServiceDeliveryConfig(raw);
|
||||
}
|
||||
|
||||
updatePushServiceDeliveryConfig(
|
||||
update: (current: PushServiceDeliveryConfig) => PushServiceDeliveryConfig,
|
||||
): Promise<PushServiceDeliveryConfig> {
|
||||
return this.updateStoredConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(
|
||||
PushServiceDeliveryConfigSchema,
|
||||
update(parseStoredPushServiceDeliveryConfig(raw)),
|
||||
'push service delivery',
|
||||
),
|
||||
);
|
||||
updatePushRelayConfig(update: (current: PushRelayConfig) => PushRelayConfig): Promise<LegacyPushServiceDeliveryWire> {
|
||||
return this.updateStoredConfig(PUSH_RELAY_CONFIG_KEY, (raw) => {
|
||||
const current = parseStoredPushRelayConfig(raw);
|
||||
const next = validateStoredConfig(PushRelayConfigSchema, update(toPushRelayConfig(current)), 'push relay');
|
||||
return toLegacyPushServiceDeliveryWire(next, current.config_version + 1);
|
||||
});
|
||||
}
|
||||
|
||||
async getDomainMigrationConfig(): Promise<DomainMigrationConfig> {
|
||||
@@ -1333,6 +1421,27 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async getProfileTimezoneConfig(): Promise<ProfileTimezoneConfig> {
|
||||
const raw = await this.getConfig(PROFILE_TIMEZONE_CONFIG_KEY);
|
||||
return parseStoredProfileTimezoneConfig(raw);
|
||||
}
|
||||
|
||||
async setProfileTimezoneConfig(config: ProfileTimezoneConfig): Promise<void> {
|
||||
await this.updateProfileTimezoneConfig(() => config);
|
||||
}
|
||||
|
||||
updateProfileTimezoneConfig(
|
||||
update: (current: ProfileTimezoneConfig) => ProfileTimezoneConfig,
|
||||
): Promise<ProfileTimezoneConfig> {
|
||||
return this.updateStoredConfig(PROFILE_TIMEZONE_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(
|
||||
ProfileTimezoneConfigSchema,
|
||||
update(parseStoredProfileTimezoneConfig(raw)),
|
||||
'profile timezone',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
|
||||
const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY);
|
||||
return parseStoredExperimentDeliveryConfig(raw);
|
||||
@@ -1382,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;
|
||||
}
|
||||
@@ -1707,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,
|
||||
|
||||
+5
-5
@@ -1,21 +1,21 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {PushServiceDeliveryConfig} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
import type {LegacyPushServiceDeliveryWire} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
export const PUSH_SERVICE_DELIVERY_CONFIG_NATS_SUBJECT = 'config.push.delivery';
|
||||
const PUSH_SERVICE_DELIVERY_CONFIG_NATS_SUBJECT = 'config.push.delivery';
|
||||
|
||||
interface PushServiceDeliveryConfigNatsMessage {
|
||||
type: 'push_service_delivery_config';
|
||||
config: PushServiceDeliveryConfig;
|
||||
config: LegacyPushServiceDeliveryWire;
|
||||
}
|
||||
|
||||
export class PushServiceDeliveryConfigPublisher {
|
||||
export class PushRelayConfigPublisher {
|
||||
constructor(private readonly connectionManager: INatsConnectionManager) {}
|
||||
|
||||
async publish(config: PushServiceDeliveryConfig): Promise<void> {
|
||||
async publish(config: LegacyPushServiceDeliveryWire): Promise<void> {
|
||||
if (this.connectionManager.isClosed()) {
|
||||
await this.connectionManager.connect();
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {createHmac} from 'node:crypto';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {ANONYMOUS_EXPERIMENT_TARGETING, resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
|
||||
import type {InstanceCaptchaEffectiveConfig} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getKVClient} from '@app/api/middleware/ServiceRegistry';
|
||||
@@ -45,7 +46,8 @@ async function altchaChallengeData(altcha: AltchaProvider | null): Promise<Recor
|
||||
|
||||
async function resolveAltchaProvider(ctx: Context<HonoEnv>, user: User | undefined): Promise<AltchaProvider | null> {
|
||||
const config = await ctx.get('instanceConfigRepository').getAltchaCaptchaConfig();
|
||||
if (!altchaCaptchaAppliesTo(config, user ? user.id.toString() : null)) return null;
|
||||
const targeting = user ? await resolveExperimentTargeting(user, [config]) : ANONYMOUS_EXPERIMENT_TARGETING;
|
||||
if (!altchaCaptchaAppliesTo(config, user ? user.id.toString() : null, targeting)) return null;
|
||||
return createAltchaProvider(config);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ import {createUsersServiceClient} from '@app/api/infrastructure/UsersServiceClie
|
||||
import {VirusScanService} from '@app/api/infrastructure/VirusScanService';
|
||||
import {GatewayRolloutConfigPublisher} from '@app/api/instance/GatewayRolloutConfigPublisher';
|
||||
import {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
|
||||
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
|
||||
import {InviteRepository} from '@app/api/invite/InviteRepository';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {LimitConfigService} from '@app/api/limits/LimitConfigService';
|
||||
@@ -157,13 +157,13 @@ export const getGatewayRolloutConfigPublisher = singleton(
|
||||
),
|
||||
);
|
||||
|
||||
export const getPushServiceDeliveryConfigPublisher = singleton(
|
||||
export const getPushRelayConfigPublisher = singleton(
|
||||
() =>
|
||||
new PushServiceDeliveryConfigPublisher(
|
||||
new PushRelayConfigPublisher(
|
||||
new NatsConnectionManager({
|
||||
url: Config.nats.coreUrl,
|
||||
token: Config.nats.authToken || undefined,
|
||||
name: 'fluxer-api-push-service-delivery-config',
|
||||
name: 'fluxer-api-push-relay-config',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
@@ -22404,14 +22405,8 @@
|
||||
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
|
||||
"description": "The user-selected accent color as an integer"
|
||||
},
|
||||
"timezone": {
|
||||
"description": "The IANA timezone identifier saved by the user. Omitted unless the user has staff access.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"timezone_privacy_flags": {
|
||||
"description": "Bitfield controlling who can see the profile timezone. Omitted unless the user has staff access.",
|
||||
"$ref": "#/components/schemas/ProfileFieldPrivacyFlags"
|
||||
},
|
||||
"timezone": {"description": "The IANA timezone identifier saved by the user", "type": ["string", "null"]},
|
||||
"timezone_privacy_flags": {"$ref": "#/components/schemas/ProfileFieldPrivacyFlags"},
|
||||
"banner": {"description": "The hash of the user profile banner image", "type": ["string", "null"]},
|
||||
"banner_color": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
|
||||
@@ -25121,11 +25116,11 @@
|
||||
"anyOf": [{"$ref": "#/components/schemas/ColorType"}, {"type": "null"}]
|
||||
},
|
||||
"timezone": {
|
||||
"description": "Staff-only IANA timezone identifier saved for profile local time. Ignored for non-staff users.",
|
||||
"description": "IANA timezone identifier saved for profile local time. Ignored unless the profile_timezone experiment serves the user.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"timezone_privacy_flags": {
|
||||
"description": "Staff-only bitfield controlling who can see the profile timezone. Ignored for non-staff users.",
|
||||
"description": "Bitfield controlling who can see the profile timezone. Ignored unless the profile_timezone experiment serves the user.",
|
||||
"$ref": "#/components/schemas/ProfileFieldPrivacyFlags"
|
||||
},
|
||||
"premium_badge_hidden": {"type": "boolean", "description": "Whether to hide the premium badge"},
|
||||
@@ -26136,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
|
||||
@@ -27952,9 +27950,9 @@
|
||||
"assignments": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionAssignmentResponse"},
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"},
|
||||
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaAssignmentResponse"}
|
||||
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaAssignmentResponse"},
|
||||
"profile_timezone": {"$ref": "#/components/schemas/ProfileTimezoneAssignmentResponse"}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -29762,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": [
|
||||
@@ -29773,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"
|
||||
@@ -29870,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",
|
||||
@@ -29884,6 +29903,8 @@
|
||||
"required": [
|
||||
"voice_enabled",
|
||||
"stripe_enabled",
|
||||
"premium_enabled",
|
||||
"stripe_serviceable",
|
||||
"self_hosted",
|
||||
"presigned_attachment_uploads",
|
||||
"emails_enabled"
|
||||
@@ -31630,6 +31651,12 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try", "sek", "dkk", "nok"]},
|
||||
"ProfileTimezoneAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean"}},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AltchaCaptchaAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean"}},
|
||||
@@ -31642,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"],
|
||||
@@ -32895,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<{
|
||||
|
||||
@@ -15,53 +15,6 @@ import {ReadStateService} from '@app/api/read_state/ReadStateService';
|
||||
import {BadGatewayError} from '@fluxer/errors/src/domains/core/BadGatewayError';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
describe('ReadStateService.bulkIncrementMentionCounts', () => {
|
||||
it('invalidates badge counts for touched users in a single bulk call', async () => {
|
||||
const channelId = createChannelID(2n);
|
||||
const messageId = createMessageID(3n);
|
||||
const touched: Array<{userId: UserID; channelId: ChannelID}> = [
|
||||
{userId: createUserID(10n), channelId},
|
||||
{userId: createUserID(11n), channelId},
|
||||
{userId: createUserID(10n), channelId: createChannelID(4n)},
|
||||
];
|
||||
const repository = {
|
||||
bulkIncrementMentionCounts: vi.fn().mockResolvedValue(touched),
|
||||
} as unknown as IReadStateRepository;
|
||||
const invalidatePushBadgeCounts = vi.fn().mockResolvedValue(undefined);
|
||||
const invalidatePushBadgeCount = vi.fn().mockResolvedValue(undefined);
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCounts,
|
||||
invalidatePushBadgeCount,
|
||||
} as unknown as IGatewayService;
|
||||
const service = new ReadStateService(repository, gatewayService);
|
||||
|
||||
await service.bulkIncrementMentionCounts([
|
||||
{userId: createUserID(10n), channelId, messageId},
|
||||
{userId: createUserID(11n), channelId, messageId},
|
||||
{userId: createUserID(12n), channelId, messageId},
|
||||
]);
|
||||
|
||||
expect(invalidatePushBadgeCount).not.toHaveBeenCalled();
|
||||
expect(invalidatePushBadgeCounts).toHaveBeenCalledTimes(1);
|
||||
expect(invalidatePushBadgeCounts).toHaveBeenCalledWith({userIds: [createUserID(10n), createUserID(11n)]});
|
||||
});
|
||||
|
||||
it('skips the bulk call when no read state was touched', async () => {
|
||||
const repository = {
|
||||
bulkIncrementMentionCounts: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as IReadStateRepository;
|
||||
const invalidatePushBadgeCounts = vi.fn().mockResolvedValue(undefined);
|
||||
const gatewayService = {invalidatePushBadgeCounts} as unknown as IGatewayService;
|
||||
const service = new ReadStateService(repository, gatewayService);
|
||||
|
||||
await service.bulkIncrementMentionCounts([
|
||||
{userId: createUserID(10n), channelId: createChannelID(2n), messageId: createMessageID(3n)},
|
||||
]);
|
||||
|
||||
expect(invalidatePushBadgeCounts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const USER_ID = createUserID(20n);
|
||||
const CHANNEL_ID = createChannelID(21n);
|
||||
const MESSAGE_ID = createMessageID(22n);
|
||||
@@ -78,7 +31,7 @@ function makeReadState(channelId: ChannelID, messageId: MessageID, mentionCount
|
||||
}
|
||||
|
||||
describe('ReadStateService gateway side effects after the write', () => {
|
||||
it('returns the committed read state when the badge invalidation fails', async () => {
|
||||
it('returns the committed read state when clearing push notifications fails', async () => {
|
||||
const stored: Array<{channelId: ChannelID; messageId: MessageID}> = [];
|
||||
const repository = {
|
||||
upsertReadState: vi.fn(async (_userId: UserID, channelId: ChannelID, messageId: MessageID) => {
|
||||
@@ -87,8 +40,7 @@ describe('ReadStateService gateway side effects after the write', () => {
|
||||
}),
|
||||
} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
|
||||
clearPushChannelNotifications: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
dispatchPresence: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as IGatewayService;
|
||||
const service = new ReadStateService(repository, gatewayService);
|
||||
@@ -113,7 +65,6 @@ describe('ReadStateService gateway side effects after the write', () => {
|
||||
),
|
||||
} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCount: vi.fn().mockResolvedValue(undefined),
|
||||
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
|
||||
dispatchPresence: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
} as unknown as IGatewayService;
|
||||
@@ -138,7 +89,6 @@ describe('ReadStateService gateway side effects after the write', () => {
|
||||
}),
|
||||
} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCount: vi.fn().mockResolvedValue(undefined),
|
||||
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
|
||||
dispatchPresence: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
} as unknown as IGatewayService;
|
||||
@@ -156,42 +106,13 @@ describe('ReadStateService gateway side effects after the write', () => {
|
||||
expect(stored).toEqual(['21', '23']);
|
||||
});
|
||||
|
||||
it('deletes the read state when the badge invalidation fails', async () => {
|
||||
const deleteReadState = vi.fn().mockResolvedValue(undefined);
|
||||
const repository = {deleteReadState} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
} as unknown as IGatewayService;
|
||||
const service = new ReadStateService(repository, gatewayService);
|
||||
|
||||
await expect(service.deleteReadState({userId: USER_ID, channelId: CHANNEL_ID})).resolves.toBeUndefined();
|
||||
|
||||
expect(deleteReadState).toHaveBeenCalledWith(USER_ID, CHANNEL_ID);
|
||||
});
|
||||
|
||||
it('increments the mention count when the badge invalidation fails', async () => {
|
||||
const incrementReadStateMentions = vi.fn().mockResolvedValue(makeReadState(CHANNEL_ID, MESSAGE_ID, 1));
|
||||
const repository = {incrementReadStateMentions} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
} as unknown as IGatewayService;
|
||||
const service = new ReadStateService(repository, gatewayService);
|
||||
|
||||
await expect(
|
||||
service.incrementMentionCount({userId: USER_ID, channelId: CHANNEL_ID, messageId: MESSAGE_ID}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(incrementReadStateMentions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns the bulk acknowledged states when the badge invalidation fails', async () => {
|
||||
it('returns the bulk acknowledged states when clearing push notifications fails', async () => {
|
||||
const updated = [makeReadState(CHANNEL_ID, MESSAGE_ID)];
|
||||
const repository = {
|
||||
bulkAckMessages: vi.fn().mockResolvedValue(updated),
|
||||
} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
|
||||
clearPushChannelNotifications: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
dispatchPresence: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as IGatewayService;
|
||||
const service = new ReadStateService(repository, gatewayService);
|
||||
|
||||
@@ -34,7 +34,6 @@ export class ReadStateService {
|
||||
undefined,
|
||||
manual ?? false,
|
||||
);
|
||||
await this.invalidatePushBadgeCount(userId);
|
||||
if (!silent) {
|
||||
await this.clearPushChannelNotifications({userId, channelId, messageId});
|
||||
}
|
||||
@@ -115,7 +114,6 @@ export class ReadStateService {
|
||||
try {
|
||||
const updatedReadStates = await this.repository.bulkAckMessages(userId, readStates);
|
||||
const readStatesByChannel = new Map(updatedReadStates.map((readState) => [readState.channelId, readState]));
|
||||
await this.invalidatePushBadgeCount(userId);
|
||||
await Promise.all(
|
||||
readStates.map(({channelId, messageId}) =>
|
||||
Promise.all([
|
||||
@@ -143,11 +141,6 @@ export class ReadStateService {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteReadState({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
|
||||
await this.repository.deleteReadState(userId, channelId);
|
||||
await this.invalidatePushBadgeCount(userId);
|
||||
}
|
||||
|
||||
async incrementMentionCount({
|
||||
userId,
|
||||
channelId,
|
||||
@@ -157,11 +150,7 @@ export class ReadStateService {
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
}): Promise<void> {
|
||||
const readState = await this.repository.incrementReadStateMentions(userId, channelId, messageId, 1);
|
||||
if (readState == null) {
|
||||
return;
|
||||
}
|
||||
await this.invalidatePushBadgeCount(userId);
|
||||
await this.repository.incrementReadStateMentions(userId, channelId, messageId, 1);
|
||||
}
|
||||
|
||||
async bulkIncrementMentionCounts(
|
||||
@@ -175,15 +164,7 @@ export class ReadStateService {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const appliedUpdates = await this.repository.bulkIncrementMentionCounts(updates);
|
||||
const uniqueUserIds = Array.from(new Set(appliedUpdates.map((update) => update.userId)));
|
||||
if (uniqueUserIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
await this.gatewayService.invalidatePushBadgeCounts({userIds: uniqueUserIds}).catch((error) => {
|
||||
Logger.error({userCount: uniqueUserIds.length, error}, 'Failed to invalidate push badge counts');
|
||||
return null;
|
||||
});
|
||||
await this.repository.bulkIncrementMentionCounts(updates);
|
||||
} catch (error) {
|
||||
Logger.error({error}, 'Bulk increment mention counts failed');
|
||||
throw error;
|
||||
@@ -196,13 +177,6 @@ export class ReadStateService {
|
||||
await this.dispatchPinsAck({userId, channelId, timestamp});
|
||||
}
|
||||
|
||||
private async invalidatePushBadgeCount(userId: UserID): Promise<void> {
|
||||
await this.gatewayService.invalidatePushBadgeCount({userId}).catch((error) => {
|
||||
Logger.error({userId: userId.toString(), error}, 'Failed to invalidate push badge count');
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchMessageAck(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
|
||||
@@ -99,7 +99,6 @@ import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
|
||||
import {UnauthorizedError} from '@fluxer/errors/src/domains/core/UnauthorizedError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import {pushServiceDeliveryEnrols} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {VoiceStateResponse} from '@fluxer/schema/src/domains/gateway/GatewaySchemas';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
@@ -433,13 +432,6 @@ export class RpcService {
|
||||
}),
|
||||
};
|
||||
case 'send_apns_push': {
|
||||
const deliveryConfig = await this.instanceConfigRepository.getPushServiceDeliveryConfig();
|
||||
if (pushServiceDeliveryEnrols(deliveryConfig, request.user_id.toString())) {
|
||||
Logger.warn(
|
||||
{userId: request.user_id.toString(), configVersion: deliveryConfig.config_version},
|
||||
'push service delivery path mismatch',
|
||||
);
|
||||
}
|
||||
const result = await sendApnsPush({
|
||||
userId: request.user_id.toString(),
|
||||
subscriptionId: request.subscription_id,
|
||||
@@ -646,7 +638,7 @@ export class RpcService {
|
||||
};
|
||||
}
|
||||
case 'get_push_service_delivery_config': {
|
||||
const config = await this.instanceConfigRepository.getPushServiceDeliveryConfig();
|
||||
const config = await this.instanceConfigRepository.getLegacyPushServiceDeliveryWire();
|
||||
return {
|
||||
type: 'get_push_service_delivery_config',
|
||||
data: {config},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -795,12 +795,6 @@ export class NoopGatewayService extends IGatewayService {
|
||||
|
||||
async dispatchPresence(_params: {userId: UserID; event: GatewayDispatchEvent; data: unknown}): Promise<void> {}
|
||||
|
||||
async invalidatePushBadgeCount(_params: {userId: UserID}): Promise<void> {}
|
||||
|
||||
async invalidatePushBadgeCounts(_params: {userIds: Array<UserID>}): Promise<void> {}
|
||||
|
||||
async invalidatePushSubscriptions(_params: {userId: UserID}): Promise<void> {}
|
||||
|
||||
async clearPushChannelNotifications(_params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||
import {resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
|
||||
import {getCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {accountPolicyContactHasCapability} from '@app/api/risk/AccountPolicyService';
|
||||
import {getCachedDeferredPhoneGateEnabled} from '@app/api/risk/DeferredPhoneGateCache';
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
SuspiciousActivityFlags,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {resolveProfileTimezoneAssignment} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import type {RequiredAction} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
@@ -289,6 +292,8 @@ export function isBugHunterBotUser(user: Pick<User, 'flags' | 'isBot'>): boolean
|
||||
return user.isBot && (user.flags & UserFlags.BUG_HUNTER) !== 0n;
|
||||
}
|
||||
|
||||
export function canUseProfileTimezone(user: Pick<PremiumCheckable, 'flags'>): boolean {
|
||||
return (user.flags & UserFlags.STAFF) !== 0n;
|
||||
export async function canUseProfileTimezone(user: User): Promise<boolean> {
|
||||
const config = await getInstanceConfigRepository().getProfileTimezoneConfig();
|
||||
const targeting = await resolveExperimentTargeting(user, [config]);
|
||||
return resolveProfileTimezoneAssignment(config, user.id.toString(), targeting).enabled;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {User} from '@app/api/models/User';
|
||||
import type {UserGuildSettings} from '@app/api/models/UserGuildSettings';
|
||||
import type {UserSettings} from '@app/api/models/UserSettings';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {canUseProfileTimezone, getRequiredActions} from '@app/api/user/UserHelpers';
|
||||
import {getRequiredActions} from '@app/api/user/UserHelpers';
|
||||
import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils';
|
||||
import type {ChannelMessageNotifications} from '@fluxer/constants/src/NotificationConstants';
|
||||
import {
|
||||
@@ -121,7 +121,6 @@ export function mapUserToPrivateResponse(user: User): UserPrivateResponse {
|
||||
const isStaff = (user.flags & UserFlags.STAFF) !== 0n;
|
||||
const partialResponse = mapUserToPartialResponse(user);
|
||||
const isActuallyPremium = user.isPremium();
|
||||
const includeProfileTimezone = canUseProfileTimezone(user);
|
||||
const traitSet = new Set<string>();
|
||||
for (const trait of user.traits ?? []) {
|
||||
if (trait && trait !== 'premium') {
|
||||
@@ -147,12 +146,8 @@ export function mapUserToPrivateResponse(user: User): UserPrivateResponse {
|
||||
bio: user.bio,
|
||||
pronouns: user.pronouns,
|
||||
accent_color: user.accentColor,
|
||||
...(includeProfileTimezone
|
||||
? {
|
||||
timezone: user.timezone,
|
||||
timezone_privacy_flags: user.timezonePrivacyFlags,
|
||||
}
|
||||
: {}),
|
||||
timezone: user.timezone,
|
||||
timezone_privacy_flags: user.timezonePrivacyFlags,
|
||||
banner: stripBannerForUser(user),
|
||||
banner_color: user.bannerColor,
|
||||
mfa_enabled: authenticatorTypes.length > 0,
|
||||
|
||||
@@ -127,10 +127,10 @@ export class UserAccountLookupService {
|
||||
: await this.getProfileFieldPrivacyContext(userId, targetId);
|
||||
const timezoneVisible =
|
||||
!restrictProfile &&
|
||||
canUseProfileTimezone(user) &&
|
||||
user.timezone != null &&
|
||||
profileFieldPrivacyContext != null &&
|
||||
this.canViewProfileField(user.timezonePrivacyFlags, profileFieldPrivacyContext);
|
||||
this.canViewProfileField(user.timezonePrivacyFlags, profileFieldPrivacyContext) &&
|
||||
(await canUseProfileTimezone(user));
|
||||
const [mutualFriends, mutualGuilds, connections] = await Promise.all([
|
||||
withMutualFriends && userId !== targetId ? this.getMutualFriends(userId, targetId) : undefined,
|
||||
withMutualGuilds && userId !== targetId ? this.getMutualGuilds(userId, targetId) : undefined,
|
||||
|
||||
@@ -83,7 +83,8 @@ export class UserAccountProfileService {
|
||||
if (data.accent_color !== undefined) {
|
||||
await this.processAccentColorUpdate({user, accentColor: data.accent_color, updates});
|
||||
}
|
||||
const canUpdateProfileTimezone = canUseProfileTimezone(user);
|
||||
const canUpdateProfileTimezone =
|
||||
(data.timezone !== undefined || data.timezone_privacy_flags !== undefined) && (await canUseProfileTimezone(user));
|
||||
if (canUpdateProfileTimezone && data.timezone !== undefined) {
|
||||
const nextTimezone = this.processTimezoneUpdate({user, timezone: data.timezone, updates});
|
||||
if (nextTimezone !== null && user.timezone === null && data.timezone_privacy_flags === undefined) {
|
||||
|
||||
@@ -85,11 +85,14 @@ function hasProfileCustomizationUpdate(data: UserUpdatePayload): boolean {
|
||||
return EMAIL_VERIFICATION_REQUIRED_PROFILE_UPDATE_FIELDS.some((field) => data[field] !== undefined);
|
||||
}
|
||||
|
||||
function stripUnauthorizedProfileTimezoneUpdate(
|
||||
async function stripUnauthorizedProfileTimezoneUpdate(
|
||||
user: User,
|
||||
body: UserUpdateWithVerificationRequest,
|
||||
): UserUpdateWithVerificationRequest {
|
||||
if (canUseProfileTimezone(user)) {
|
||||
): Promise<UserUpdateWithVerificationRequest> {
|
||||
if (body.timezone === undefined && body.timezone_privacy_flags === undefined) {
|
||||
return body;
|
||||
}
|
||||
if (await canUseProfileTimezone(user)) {
|
||||
return body;
|
||||
}
|
||||
const {timezone: _timezone, timezone_privacy_flags: _timezonePrivacyFlags, ...rest} = body;
|
||||
@@ -187,7 +190,7 @@ export class UserAccountRequestService {
|
||||
const {ctx, body, authSession} = params;
|
||||
let {user} = params;
|
||||
const oldEmail = user.email;
|
||||
const sanitizedBody = stripUnauthorizedProfileTimezoneUpdate(user, body);
|
||||
const sanitizedBody = await stripUnauthorizedProfileTimezoneUpdate(user, body);
|
||||
const {
|
||||
mfa_method: _mfaMethod,
|
||||
mfa_code: _mfaCode,
|
||||
|
||||
@@ -392,7 +392,6 @@ export class UserContentService {
|
||||
provider_environment: null,
|
||||
};
|
||||
const subscription = await this.storeWebPushSubscription(data, originKind ?? null, installedApp === true);
|
||||
await this.gatewayService.invalidatePushSubscriptions({userId});
|
||||
return subscription;
|
||||
}
|
||||
|
||||
@@ -468,7 +467,6 @@ export class UserContentService {
|
||||
|
||||
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
|
||||
await this.userRepository.deletePushSubscription(userId, subscriptionId);
|
||||
await this.gatewayService.invalidatePushSubscriptions({userId});
|
||||
}
|
||||
|
||||
async rotatePushSubscription(params: {
|
||||
@@ -504,7 +502,6 @@ export class UserContentService {
|
||||
provider_environment: null,
|
||||
};
|
||||
const subscription = await this.storeWebPushSubscription(data, originKind ?? null, installedApp === true);
|
||||
await this.gatewayService.invalidatePushSubscriptions({userId});
|
||||
return subscription;
|
||||
}
|
||||
|
||||
@@ -530,7 +527,6 @@ export class UserContentService {
|
||||
provider_environment: providerEnvironment,
|
||||
};
|
||||
const subscription = await this.userRepository.createPushSubscription(data);
|
||||
await this.gatewayService.invalidatePushSubscriptions({userId});
|
||||
return subscription;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createFriendship} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
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 {
|
||||
ProfileFieldPrivacyFlags,
|
||||
type ProfilePrivacyLevel,
|
||||
ProfilePrivacyLevels,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {getCurrentTimeZoneOffsetMinutes} from '@fluxer/date_utils/src/TimeZoneUtils';
|
||||
import {DEFAULT_PROFILE_TIMEZONE_CONFIG} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import type {UserPrivateResponse, UserProfileFullResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
@@ -31,12 +31,12 @@ async function updateProfileTimezone(
|
||||
return createBuilder<UserPrivateResponse>(harness, token).patch('/users/@me').body(data).execute();
|
||||
}
|
||||
|
||||
async function setUserFlags(harness: ApiTestHarness, userId: string, flags: bigint): Promise<void> {
|
||||
await createBuilder(harness, '')
|
||||
.patch(`/test/users/${userId}/flags`)
|
||||
.body({flags: flags.toString()})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
async function setProfileTimezoneUsers(userIds: Array<string>): Promise<void> {
|
||||
await getInstanceConfigRepository().setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
enabled: true,
|
||||
included_user_ids: userIds,
|
||||
});
|
||||
}
|
||||
|
||||
async function updateProfilePrivacy(
|
||||
@@ -76,7 +76,7 @@ describe('User Profile Timezone Visibility', () => {
|
||||
it('defaults timezone visibility to everyone when a timezone is set', async () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
const viewerAccount = await createTestAccount(harness);
|
||||
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
|
||||
expect(updated.timezone).toBe(TEST_TIMEZONE);
|
||||
expect(updated.timezone_privacy_flags).toBe(ProfileFieldPrivacyFlags.EVERYONE);
|
||||
@@ -86,7 +86,7 @@ describe('User Profile Timezone Visibility', () => {
|
||||
});
|
||||
it('restores default timezone visibility when a timezone is set again without explicit flags', async () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
await updateProfileTimezone(harness, targetAccount.token, {
|
||||
timezone: TEST_TIMEZONE,
|
||||
timezone_privacy_flags: 0,
|
||||
@@ -97,7 +97,7 @@ describe('User Profile Timezone Visibility', () => {
|
||||
});
|
||||
it('hides timezone from the public profile when privacy flags are unset', async () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
await updateProfileTimezone(harness, targetAccount.token, {
|
||||
timezone: TEST_TIMEZONE,
|
||||
timezone_privacy_flags: 0,
|
||||
@@ -109,7 +109,7 @@ describe('User Profile Timezone Visibility', () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
const friendAccount = await createTestAccount(harness);
|
||||
const guildMemberAccount = await createTestAccount(harness);
|
||||
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
await updateProfileTimezone(harness, targetAccount.token, {
|
||||
timezone: TEST_TIMEZONE,
|
||||
timezone_privacy_flags: ProfileFieldPrivacyFlags.FRIENDS,
|
||||
@@ -125,7 +125,7 @@ describe('User Profile Timezone Visibility', () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
const friendAccount = await createTestAccount(harness);
|
||||
const guildMemberAccount = await createTestAccount(harness);
|
||||
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
await updateProfileTimezone(harness, targetAccount.token, {
|
||||
timezone: TEST_TIMEZONE,
|
||||
timezone_privacy_flags: ProfileFieldPrivacyFlags.MUTUAL_GUILDS,
|
||||
@@ -140,7 +140,7 @@ describe('User Profile Timezone Visibility', () => {
|
||||
it('hides timezone when full profile privacy restricts the viewer', async () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
const guildMemberAccount = await createTestAccount(harness);
|
||||
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
|
||||
await updateProfilePrivacy(harness, targetAccount.token, ProfilePrivacyLevels.FRIENDS_ONLY);
|
||||
await createSharedGuild(harness, targetAccount.token, guildMemberAccount.token);
|
||||
@@ -148,23 +148,47 @@ describe('User Profile Timezone Visibility', () => {
|
||||
expect(profile.profile_limited).toBe(true);
|
||||
expect(profile.timezone_offset).toBeNull();
|
||||
});
|
||||
it('ignores profile timezone updates from non-staff users', async () => {
|
||||
it('ignores profile timezone updates from users outside the experiment', async () => {
|
||||
const targetAccount = await createTestAccount(harness, {skipEmailVerification: true});
|
||||
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
|
||||
expect(updated).not.toHaveProperty('timezone');
|
||||
expect(updated).not.toHaveProperty('timezone_privacy_flags');
|
||||
const updated = await updateProfileTimezone(harness, targetAccount.token, {
|
||||
timezone: TEST_TIMEZONE,
|
||||
timezone_privacy_flags: ProfileFieldPrivacyFlags.FRIENDS,
|
||||
});
|
||||
expect(updated.timezone).toBeNull();
|
||||
expect(updated.timezone_privacy_flags).toBe(ProfileFieldPrivacyFlags.EVERYONE);
|
||||
});
|
||||
it('hides stored profile timezone after the user no longer has the staff flag', async () => {
|
||||
it('ignores profile timezone updates from users excluded from a full rollout', async () => {
|
||||
const targetAccount = await createTestAccount(harness, {skipEmailVerification: true});
|
||||
await getInstanceConfigRepository().setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
excluded_user_ids: [targetAccount.userId],
|
||||
});
|
||||
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
|
||||
expect(updated.timezone).toBeNull();
|
||||
});
|
||||
it('lets members of an included guild set and show a timezone', async () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
const viewerAccount = await createTestAccount(harness);
|
||||
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
|
||||
const guild = await createGuild(harness, targetAccount.token, 'Timezone Rollout Guild');
|
||||
await getInstanceConfigRepository().setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
|
||||
expect(updated.timezone).toBe(TEST_TIMEZONE);
|
||||
await createFriendship(harness, targetAccount, viewerAccount);
|
||||
const profile = await getUserProfile(harness, viewerAccount.token, targetAccount.userId);
|
||||
expect(profile.timezone_offset).toBe(TEST_TIMEZONE_OFFSET);
|
||||
});
|
||||
it('hides stored profile timezone after the user leaves the experiment', async () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
const viewerAccount = await createTestAccount(harness);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
|
||||
await setUserFlags(harness, targetAccount.userId, 0n);
|
||||
const currentUser = await createBuilder<UserPrivateResponse>(harness, targetAccount.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(currentUser).not.toHaveProperty('timezone');
|
||||
expect(currentUser).not.toHaveProperty('timezone_privacy_flags');
|
||||
await setProfileTimezoneUsers([]);
|
||||
await createFriendship(harness, targetAccount, viewerAccount);
|
||||
const profile = await getUserProfile(harness, viewerAccount.token, targetAccount.userId);
|
||||
expect(profile.timezone_offset).toBeNull();
|
||||
|
||||
@@ -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}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user