Compare commits

...
Author SHA1 Message Date
HampusandGitHub 910db6734b feat(experiments): ship seven treatments to everyone (#2798) 2026-09-15 18:03:35 +02:00
HampusandGitHub 9e614026d7 fix(api): stop exporting the change feed stats type (#2797) 2026-09-15 17:27:09 +02:00
HampusandGitHub b38e7c6433 feat(api): publish object storage changes to a JetStream feed (#2796) 2026-09-15 17:20:26 +02:00
HampusandGitHub 83c8e91955 fix(app): fit the user area popout shadow to its card (#2795) 2026-09-15 17:11:43 +02:00
HampusandGitHub 0532dd0440 fix(app): stop guild banner jumps and restore hover animation (#2792) 2026-09-15 09:31:52 +02:00
HampusandGitHub a08615e306 fix(gateway): match member search on username and global name (#2791) 2026-09-15 08:48:33 +02:00
HampusandGitHub f4c5fee17e feat(app): rank forward destinations and preview the message (#2790) 2026-09-15 07:23:26 +02:00
HampusandGitHub c5aaf65a10 fix(app): list friends with closed DMs in the forward modal (#2787) 2026-09-15 00:39:18 +02:00
HampusandGitHub 951e39da3d feat(api): add expression source guild routes (#2786) 2026-09-15 00:31:47 +02:00
HampusandGitHub b693d84d2b fix(openapi): restore named discriminated union branches (#2785) 2026-09-14 23:42:26 +02:00
HampusandGitHub 9bbf6c513b fix(installer): say what the email prompt is for (#2784) 2026-09-14 23:07:03 +02:00
HampusandGitHub 50cec92738 fix(api): batch member user lookups on guild load (#2783) 2026-09-14 23:06:36 +02:00
HampusandGitHub c212d315f4 fix(app): gate reworked typing indicators behind an experiment (#2782) 2026-09-14 21:54:52 +02:00
HampusandGitHub 1861432a53 fix(app): scope message rings and reach the composer by key (#2781) 2026-09-14 21:44:12 +02:00
HampusandGitHub d0c6146429 feat(app): gate a collapsing guild header behind an experiment (#2779) 2026-09-14 21:15:31 +02:00
HampusandGitHub 550e6b05a1 fix(app): show hover highlight and inset the focus ring (#2778) 2026-09-14 20:51:59 +02:00
HampusandGitHub 5b6949170f chore(donations): drop the donor email case backfill script (#2776) 2026-09-14 20:45:38 +02:00
HampusandGitHub f32bc37794 fix(guild): correct activity log sentence presentation (#2777) 2026-09-14 20:43:08 +02:00
HampusandGitHub 8ee2279b4b feat(donations): add Nordic currencies, raise amount ceilings (#2775) 2026-09-14 20:27:24 +02:00
HampusandGitHub 6339c3b8ad feat(app): gate the expression info card behind an experiment (#2773) 2026-09-14 20:14:58 +02:00
HampusandGitHub 7c9274847f feat(gateway): list bot ready guilds as unavailable (#2774) 2026-09-14 20:08:26 +02:00
HampusandGitHub 5d1dddc093 fix(deps): update rustls for RUSTSEC-2026-0285 (#2772) 2026-09-14 19:01:22 +02:00
HampusandGitHub 04481d7235 fix(app): keep blockquotes open across pasted lines (#2771) 2026-09-14 18:54:04 +02:00
Hampus Kraft a3d6cf37cb fix(guild): render activity log entries deterministically (#2766) 2026-09-14 17:39:19 +02:00
HampusandGitHub ed10f9d323 fix(app): gate blocked group rendering behind an experiment (#2763) 2026-09-14 16:08:19 +02:00
HampusandGitHub 4b278a0da8 fix(app): gate one-Tab message focus behind an experiment (#2762) 2026-09-14 15:55:00 +02:00
HampusandGitHub 1281045648 fix(app): gate single-source message hover behind an experiment (#2761) 2026-09-14 15:37:37 +02:00
HampusandGitHub 69c42cff90 docs: reword vague sentences and fix wrong claims (#2760) 2026-09-14 15:18:53 +02:00
HampusandGitHub 7d56481aba fix(app): copy selected message text without markdown (#2759) 2026-09-14 14:53:53 +02:00
527 changed files with 100822 additions and 52695 deletions
Generated
+14 -13
View File
@@ -266,9 +266,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.16.3"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"zeroize",
@@ -276,14 +276,15 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.40.0"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
@@ -1622,7 +1623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -3988,14 +3989,14 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
name = "rustls"
version = "0.23.40"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"log",
@@ -4047,7 +4048,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -4058,9 +4059,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
@@ -4671,7 +4672,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -5539,7 +5540,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
+3 -2
View File
@@ -365,8 +365,9 @@ FLUXER_DISCOVERY_ENABLED=true
# messages, 320 for snowflakes and 64 elsewhere, and they govern every service
# Compose does not forward this to. A router holds a slot for the whole round
# trip to its shard, so this is a ceiling on requests in flight at once and not a
# rate: too low a value does not slow requests down, it rejects them, and the api
# turns that rejection into a 503.
# rate: too low a value does not slow requests down, it rejects them. The api
# turns that rejection into a 503 and logs "shard rejected the request because
# it is at its concurrency limit".
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=192
# The api and the Rust services name their fixed Postgres statement shapes so the
+301
View File
@@ -38,6 +38,8 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
let mut spec: openapiv3::OpenAPI =
serde_json::from_str(&json_str).expect("failed to parse openapi-admin.json");
adapt_progenitor_throttled_errors(&mut spec);
relax_guild_audit_log_schemas(&mut spec);
relax_progenitor_schema_strictness(&mut spec);
let mut settings = progenitor::GenerationSettings::new();
settings.with_interface(progenitor::InterfaceStyle::Positional);
@@ -141,6 +143,305 @@ fn adapt_progenitor_throttled_errors(spec: &mut openapiv3::OpenAPI) {
}
}
fn relax_guild_audit_log_schemas(spec: &mut openapiv3::OpenAPI) {
let components = spec.components.as_mut().expect("missing API components");
let entry = object_schema_mut(components, "GuildAuditLogEntryResponse");
entry.additional_properties = None;
let openapiv3::ReferenceOr::Item(options) = entry
.properties
.get_mut("options")
.expect("GuildAuditLogEntryResponse has no options property")
else {
panic!("GuildAuditLogEntryResponse options must be an inline schema");
};
let openapiv3::SchemaKind::Type(openapiv3::Type::Object(options)) = &mut options.schema_kind
else {
panic!("GuildAuditLogEntryResponse options must be an object schema");
};
options.additional_properties = None;
let change = object_schema_mut(components, "AuditLogChangeSchema");
change.additional_properties = None;
for property in ["old_value", "new_value"] {
change.properties.insert(
property.to_string(),
openapiv3::ReferenceOr::Item(Box::new(openapiv3::Schema {
schema_data: openapiv3::SchemaData::default(),
schema_kind: openapiv3::SchemaKind::Any(openapiv3::AnySchema::default()),
})),
);
}
}
fn object_schema_mut<'a>(
components: &'a mut openapiv3::Components,
name: &str,
) -> &'a mut openapiv3::ObjectType {
let Some(openapiv3::ReferenceOr::Item(schema)) = components.schemas.get_mut(name) else {
panic!("missing inline {name} schema");
};
let openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) = &mut schema.schema_kind
else {
panic!("{name} must be an object schema");
};
object
}
const MAX_SCHEMA_REFERENCE_DEPTH: usize = 32;
fn relax_progenitor_schema_strictness(spec: &mut openapiv3::OpenAPI) {
let registry = spec.components.clone().unwrap_or_default();
if let Some(components) = spec.components.as_mut() {
for schema in components.schemas.values_mut() {
relax_schema_reference(schema, &registry);
}
for response in components.responses.values_mut() {
if let openapiv3::ReferenceOr::Item(response) = response {
relax_response(response, &registry);
}
}
for parameter in components.parameters.values_mut() {
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
relax_parameter(parameter, &registry);
}
}
for request_body in components.request_bodies.values_mut() {
if let openapiv3::ReferenceOr::Item(request_body) = request_body {
relax_content(&mut request_body.content, &registry);
}
}
for header in components.headers.values_mut() {
if let openapiv3::ReferenceOr::Item(header) = header {
relax_parameter_format(&mut header.format, &registry);
}
}
}
for path in spec.paths.paths.values_mut() {
let openapiv3::ReferenceOr::Item(path) = path else {
continue;
};
for parameter in &mut path.parameters {
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
relax_parameter(parameter, &registry);
}
}
for operation in [
&mut path.get,
&mut path.put,
&mut path.post,
&mut path.delete,
&mut path.options,
&mut path.head,
&mut path.patch,
&mut path.trace,
]
.into_iter()
.flatten()
{
for parameter in &mut operation.parameters {
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
relax_parameter(parameter, &registry);
}
}
if let Some(openapiv3::ReferenceOr::Item(request_body)) =
operation.request_body.as_mut()
{
relax_content(&mut request_body.content, &registry);
}
for response in operation
.responses
.responses
.values_mut()
.chain(operation.responses.default.iter_mut())
{
if let openapiv3::ReferenceOr::Item(response) = response {
relax_response(response, &registry);
}
}
}
}
}
fn relax_response(response: &mut openapiv3::Response, registry: &openapiv3::Components) {
relax_content(&mut response.content, registry);
for header in response.headers.values_mut() {
if let openapiv3::ReferenceOr::Item(header) = header {
relax_parameter_format(&mut header.format, registry);
}
}
}
fn relax_content(content: &mut openapiv3::Content, registry: &openapiv3::Components) {
for media_type in content.values_mut() {
if let Some(schema) = media_type.schema.as_mut() {
relax_schema_reference(schema, registry);
}
}
}
fn relax_parameter(parameter: &mut openapiv3::Parameter, registry: &openapiv3::Components) {
let format = match parameter {
openapiv3::Parameter::Query { parameter_data, .. }
| openapiv3::Parameter::Header { parameter_data, .. }
| openapiv3::Parameter::Path { parameter_data, .. }
| openapiv3::Parameter::Cookie { parameter_data, .. } => &mut parameter_data.format,
};
relax_parameter_format(format, registry);
}
fn relax_parameter_format(
format: &mut openapiv3::ParameterSchemaOrContent,
registry: &openapiv3::Components,
) {
match format {
openapiv3::ParameterSchemaOrContent::Schema(schema) => {
relax_schema_reference(schema, registry)
}
openapiv3::ParameterSchemaOrContent::Content(content) => relax_content(content, registry),
}
}
fn relax_schema_reference(
schema: &mut openapiv3::ReferenceOr<openapiv3::Schema>,
registry: &openapiv3::Components,
) {
if let openapiv3::ReferenceOr::Item(schema) = schema {
relax_schema(schema, registry);
}
}
fn relax_boxed_schema_reference(
schema: &mut openapiv3::ReferenceOr<Box<openapiv3::Schema>>,
registry: &openapiv3::Components,
) {
if let openapiv3::ReferenceOr::Item(schema) = schema {
relax_schema(schema, registry);
}
}
fn relax_schema(schema: &mut openapiv3::Schema, registry: &openapiv3::Components) {
if flattens_objects_beside_scalars(&schema.schema_kind, registry) {
schema.schema_kind = openapiv3::SchemaKind::Any(openapiv3::AnySchema::default());
return;
}
match &mut schema.schema_kind {
openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
relax_additional_properties(&mut object.additional_properties, registry);
for property in object.properties.values_mut() {
relax_boxed_schema_reference(property, registry);
}
}
openapiv3::SchemaKind::Type(openapiv3::Type::Array(array)) => {
if let Some(items) = array.items.as_mut() {
relax_boxed_schema_reference(items, registry);
}
}
openapiv3::SchemaKind::Type(_) => {}
openapiv3::SchemaKind::OneOf { one_of: subschemas }
| openapiv3::SchemaKind::AllOf { all_of: subschemas }
| openapiv3::SchemaKind::AnyOf { any_of: subschemas } => {
for subschema in subschemas {
relax_schema_reference(subschema, registry);
}
}
openapiv3::SchemaKind::Not { not } => relax_schema_reference(not, registry),
openapiv3::SchemaKind::Any(any) => {
relax_additional_properties(&mut any.additional_properties, registry);
for property in any.properties.values_mut() {
relax_boxed_schema_reference(property, registry);
}
if let Some(items) = any.items.as_mut() {
relax_boxed_schema_reference(items, registry);
}
for subschema in any
.one_of
.iter_mut()
.chain(any.all_of.iter_mut())
.chain(any.any_of.iter_mut())
{
relax_schema_reference(subschema, registry);
}
if let Some(not) = any.not.as_mut() {
relax_schema_reference(not, registry);
}
}
}
}
fn relax_additional_properties(
additional_properties: &mut Option<openapiv3::AdditionalProperties>,
registry: &openapiv3::Components,
) {
match additional_properties {
Some(openapiv3::AdditionalProperties::Any(false)) => *additional_properties = None,
Some(openapiv3::AdditionalProperties::Schema(schema)) => {
relax_schema_reference(schema, registry)
}
_ => {}
}
}
fn flattens_objects_beside_scalars(
schema_kind: &openapiv3::SchemaKind,
registry: &openapiv3::Components,
) -> bool {
let subschemas = match schema_kind {
openapiv3::SchemaKind::OneOf { one_of } => one_of,
openapiv3::SchemaKind::AnyOf { any_of } => any_of,
_ => return false,
};
let mut objects = false;
let mut scalars = false;
for subschema in subschemas {
if resolves_to_object(subschema, registry, MAX_SCHEMA_REFERENCE_DEPTH) {
objects = true;
} else {
scalars = true;
}
}
objects && scalars
}
fn resolves_to_object(
schema: &openapiv3::ReferenceOr<openapiv3::Schema>,
registry: &openapiv3::Components,
depth: usize,
) -> bool {
let Some(depth) = depth.checked_sub(1) else {
return false;
};
let schema = match schema {
openapiv3::ReferenceOr::Reference { reference } => {
let Some(target) = reference
.strip_prefix("#/components/schemas/")
.and_then(|name| registry.schemas.get(name))
else {
return false;
};
return resolves_to_object(target, registry, depth);
}
openapiv3::ReferenceOr::Item(schema) => schema,
};
match &schema.schema_kind {
openapiv3::SchemaKind::Type(openapiv3::Type::Object(_)) => true,
openapiv3::SchemaKind::Type(_) => false,
openapiv3::SchemaKind::OneOf { one_of: subschemas }
| openapiv3::SchemaKind::AllOf { all_of: subschemas }
| openapiv3::SchemaKind::AnyOf { any_of: subschemas } => subschemas
.iter()
.any(|subschema| resolves_to_object(subschema, registry, depth)),
openapiv3::SchemaKind::Not { .. } => false,
openapiv3::SchemaKind::Any(any) => {
any.typ.as_deref() == Some("object")
|| !any.properties.is_empty()
|| any.additional_properties.is_some()
}
}
}
struct Face {
css_family: String,
weight: u64,
+14 -4
View File
@@ -14917,7 +14917,10 @@
"nullable": true,
"type": "string"
},
"reason": {"description": "The reason provided for the action", "type": "string"},
"reason": {
"description": "The audit log reason. For bans and timeouts without an X-Audit-Log-Reason header this is the reason sent in the request body",
"type": "string"
},
"options": {
"description": "Additional options depending on action type",
"type": "object",
@@ -14925,15 +14928,22 @@
"channel_id": {"description": "Channel ID for relevant actions", "type": "string"},
"count": {"description": "Count of items affected", "type": "number"},
"delete_member_days": {
"description": "Number of days of messages to delete on member ban",
"description": "Deprecated. Whole days of messages deleted, written only by bans recorded before delete_message_seconds",
"type": "string"
},
"delete_message_seconds": {
"description": "Seconds of the banned user's messages that the ban deleted, present only when positive",
"type": "number"
},
"id": {"description": "ID of the affected entity", "type": "string"},
"integration_type": {"description": "Type of integration", "type": "number"},
"message_id": {"description": "Message ID for relevant actions", "type": "string"},
"members_removed": {"description": "Number of members removed", "type": "number"},
"role_name": {"description": "Name of the role", "type": "string"},
"type": {"description": "Type identifier", "type": "number"},
"role_name": {"description": "Name of the role when the entry was written", "type": "string"},
"type": {
"description": "Channel type for CHANNEL_CREATE, CHANNEL_UPDATE and CHANNEL_DELETE. Overwrite target type (0 role, 1 member) for CHANNEL_OVERWRITE_*",
"type": "number"
},
"inviter_id": {"description": "ID of the user who created the invite", "type": "string"},
"max_age": {"description": "Maximum age of the invite in seconds", "type": "number"},
"max_uses": {"description": "Maximum number of uses for the invite", "type": "number"},
+321
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use fluxer_admin::api::generated::types as generated_types;
use fluxer_admin::api::types;
#[test]
@@ -247,6 +248,326 @@ fn deserialize_audit_logs_response() {
assert_eq!(resp.logs[0].metadata.get("session_count").unwrap(), "3");
}
#[test]
fn deserialize_guild_audit_logs_with_unknown_keys() {
let json = r#"{
"audit_log_entries": [
{
"id": "1508822460457747581",
"action_type": 22,
"user_id": "1130650140672000000",
"target_id": "1130958221824557056",
"reason": "spam",
"options": {
"delete_message_seconds": 3600,
"future_option": {"nested": true}
},
"changes": [
{"key": "future_change", "future_field": 1}
],
"future_entry_field": "ignored"
},
{
"id": "1508822460457747580",
"action_type": 31,
"user_id": "1130650140672000000",
"target_id": "1130958221824557057",
"options": {
"id": "1130958221824557057",
"type": 0,
"channel_id": "1130958221824557058",
"role_name": "Moderators"
},
"changes": [
{"key": "permissions_diff", "new_value": {"added": ["SEND_MESSAGES"], "removed": []}}
]
},
{
"id": "1508822460457747579",
"action_type": 11,
"user_id": "1130650140672000000",
"target_id": "1130958221824557059",
"options": {"type": 998},
"changes": [
{"key": "name", "old_value": "old-name", "new_value": "new-name"},
{"key": "position", "old_value": 3, "new_value": 4},
{"key": "nsfw", "new_value": true},
{"key": "roles", "new_value": ["1130958221824557060", "1130958221824557061"]}
]
}
],
"users": [],
"webhooks": []
}"#;
let resp: generated_types::ListGuildAuditLogsResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.audit_log_entries.len(), 3);
let ban = &resp.audit_log_entries[0];
assert_eq!(*ban.action_type, 22);
assert_eq!(ban.reason.as_deref(), Some("spam"));
assert!(ban.options.is_some());
assert_eq!(ban.changes.len(), 1);
assert_eq!(ban.changes[0].key, "future_change");
assert!(ban.changes[0].new_value.is_none());
let role_update = &resp.audit_log_entries[1];
let options = role_update.options.as_ref().unwrap();
assert_eq!(options.role_name.as_deref(), Some("Moderators"));
assert_eq!(options.channel_id.as_deref(), Some("1130958221824557058"));
assert_eq!(options.type_, Some(0.0));
assert_eq!(role_update.changes[0].key, "permissions_diff");
assert!(role_update.changes[0].new_value.is_some());
let channel_update = &resp.audit_log_entries[2];
assert_eq!(channel_update.changes.len(), 4);
assert_eq!(
channel_update.changes[0]
.old_value
.as_ref()
.and_then(|value| value.as_str()),
Some("old-name")
);
assert_eq!(
channel_update.changes[0]
.new_value
.as_ref()
.and_then(|value| value.as_str()),
Some("new-name")
);
assert_eq!(
channel_update.changes[1]
.new_value
.as_ref()
.and_then(|value| value.as_i64()),
Some(4)
);
assert_eq!(
channel_update.changes[2]
.new_value
.as_ref()
.and_then(|value| value.as_bool()),
Some(true)
);
assert_eq!(
channel_update.changes[3]
.new_value
.as_ref()
.and_then(|value| value.as_array())
.map(|value| value.len()),
Some(2)
);
}
#[test]
fn deserialize_instance_config_response_with_unknown_keys() {
let json = r##"{
"sso": {
"enabled": true,
"enforced": false,
"display_name": "Fluxer SSO",
"issuer": "https://id.example.com",
"authorization_url": "https://id.example.com/authorize",
"token_url": "https://id.example.com/token",
"userinfo_url": "https://id.example.com/userinfo",
"jwks_url": "https://id.example.com/jwks.json",
"client_id": "fluxer-admin",
"client_secret_set": true,
"scope": "openid profile email",
"allowed_domains": ["fluxer.com"],
"auto_provision": true,
"redirect_uri": "https://fluxer.com/sso/callback"
},
"gateway_rollout": {
"session_rollout_percentage": 100,
"session_rollout_mode": "modulo",
"guild_rollout_percentage": 100,
"rpc_request_timeout_ms": 10000,
"max_concurrent_session_starts": 512,
"max_concurrent_guild_starts": 256,
"gateway_dispatch_relay_shards": 32,
"gateway_dispatch_relay_max_queue": 50000,
"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": [],
"stereo_enabled": false,
"suppression_strength": 80,
"future_presentation_knob": "verbose",
"future_knob": 7,
"future_object_knob": {"nested": true},
"future_list_knob": ["a", "b"]
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
"registration": {
"mode": "open",
"admin_registration_urls_enabled": false,
"urls": [],
"pending_registrations": []
},
"self_hosted": false,
"app_public": {
"branding": {
"product_name": "Fluxer",
"icon_url": "https://cdn.example.com/icon.png",
"symbol_url": "https://cdn.example.com/symbol.svg",
"logo_url": "https://cdn.example.com/logo.svg",
"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"
},
"setup": {"configured": true},
"legal": {
"terms_url": "https://fluxer.com/terms",
"privacy_url": "https://fluxer.com/privacy"
},
"registration": {"collect_date_of_birth": true}
},
"policy": {
"single_community_enabled": false,
"single_community_guild_id": null,
"direct_messages_disabled": false,
"direct_messages_locked": false,
"premium_mode": "mirror",
"services": {
"gif_enabled": true,
"youtube_enabled": true,
"bluesky_enabled": false,
"future_service_enabled": true
},
"services_resolved": {
"gif_enabled": true,
"youtube_enabled": true,
"bluesky_enabled": false
},
"services_available": {"gif": true, "youtube": true, "bluesky": false},
"deferred_phone_gate": {
"enabled": false,
"window_hours": 24,
"member_threshold": 100
}
},
"integrations": {
"gif": {"klipy_api_key_set": true, "effective_available": true},
"youtube": {"api_key_set": true, "effective_available": true},
"captcha": {
"provider": "hcaptcha",
"effective_provider": "hcaptcha",
"hcaptcha_site_key": "site",
"hcaptcha_secret_key_set": true,
"turnstile_site_key": "",
"turnstile_secret_key_set": false,
"effective_enabled": true
},
"email": {
"enabled": true,
"effective_enabled": true,
"provider": "smtp",
"effective_provider": "smtp",
"from_email": "[email protected]",
"from_name": "Fluxer",
"smtp": {
"host": "smtp.example.com",
"port": 587,
"username": "fluxer",
"password_set": true,
"secure": true
},
"disable_new_ip_authorization": false,
"effective_disable_new_ip_authorization": false
},
"bluesky": {
"enabled": false,
"effective_enabled": false,
"client_name": "Fluxer",
"client_uri": "https://fluxer.com",
"logo_uri": "https://cdn.example.com/logo.svg",
"tos_uri": "https://fluxer.com/terms",
"policy_uri": "https://fluxer.com/privacy",
"key_count": 0
}
},
"media": {
"attachment_decay": {
"enabled": true,
"min_size_mb": 10,
"max_size_mb": 500,
"max_eligible_size_mb": 500,
"min_lifetime_days": 30,
"max_lifetime_days": 365,
"curve": 2,
"renew_threshold_days": 7,
"renew_window_days": 14,
"effective": {
"enabled": true,
"min_size_mb": 10,
"max_size_mb": 500,
"max_eligible_size_mb": 500,
"min_lifetime_days": 30,
"max_lifetime_days": 365,
"curve": 2,
"renew_threshold_days": 7,
"renew_window_days": 14,
"future_curve": 1.5
}
}
},
"future_section": {"enabled": true, "rollout_basis_points": 10000},
"future_flag": 3
}"##;
let resp: generated_types::InstanceConfigResponse = serde_json::from_str(json).expect(
"the build.rs relaxation must keep unknown response keys from failing the whole response",
);
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_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!(resp.media.attachment_decay.effective.enabled);
let without_unknown_keys = json
.replace("\"future_rollout_knob\": 3,", "")
.replace("\"future_presentation_knob\": \"verbose\",", "")
.replace("\"future_knob\": 7,", "")
.replace("\"future_object_knob\": {\"nested\": true},", "")
.replace("\"future_list_knob\": [\"a\", \"b\"],", "")
.replace(
"\"future_asset_url\": \"https://cdn.example.com/future.png\",",
"",
)
.replace("\"future_service_enabled\": true,", "")
.replace("\"future_curve\": 1.5,", "")
.replace(
"\"future_section\": {\"enabled\": true, \"rollout_basis_points\": 10000},",
"",
)
.replace("\"future_flag\": 3,", "");
let baseline: generated_types::InstanceConfigResponse =
serde_json::from_str(&without_unknown_keys).expect("known keys alone still deserialize");
assert_eq!(
serde_json::to_value(&baseline).unwrap(),
serde_json::to_value(&resp).unwrap()
);
}
#[test]
fn deserialize_search_reports_response() {
let json = r#"{
+8 -1
View File
@@ -9,6 +9,13 @@ import type {IEmailService} from '@pkgs/email/src/IEmailService';
import {ms} from 'itty-time';
const logger = createLogger('email-service');
const DEFAULT_LOCALE = 'en-US';
function formatMinorUnitAmount(amountMinor: number, currency: string, locale: string | null): string {
const formatter = new Intl.NumberFormat(locale || DEFAULT_LOCALE, {style: 'currency', currency});
const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2;
return formatter.format(amountMinor / 10 ** fractionDigits);
}
export class EmailService implements IEmailService {
private readonly config: EmailConfig;
@@ -301,7 +308,7 @@ export class EmailService implements IEmailService {
locale: string | null = null,
): Promise<boolean> {
return this.sendTemplatedEmail(email, 'donation_confirmation', locale, {
amount: (amountCents / 100).toFixed(2),
amount: formatMinorUnitAmount(amountCents, currency, locale),
currency: currency.toUpperCase(),
interval,
manageUrl,
@@ -17,7 +17,7 @@ export const EMAIL_I18N_MESSAGES = {
},
donation_confirmation: {
subject: 'Thank you for your {product_name} donation',
body: 'Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {currency} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team',
body: 'Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team',
},
donation_magic_link: {
subject: 'Manage your {product_name} donations',
@@ -17,7 +17,7 @@ const EMAIL_I18N_AR_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "شكرًا لك على تبرعك لـ{product_name}",
"body": "مرحبًا،\n\nشكرًا لك على تبرعك لـ{product_name}! لقد تم {interval, select,\n month {إعداد تبرعك المتكرر}\n year {إعداد تبرعك المتكرر}\n other {معالجة تبرعك لمرة واحدة}\n} بنجاح.\n\nتفاصيل التبرع:\nالمبلغ: {amount} {currency} {interval, select,\n month {شهريًا}\n year {سنويًا}\n other {}\n}\n\nسترسل لك Stripe إيصالًا منفصلًا عبر البريد الإلكتروني مع فاتورتك بصيغة PDF قريبًا. يتضمن هذا جميع تفاصيل الدفع ويمكن استخدامه لأغراض ضريبية.\n\nيمكنك عرض سجل تبرعاتك، وتنزيل الفواتير، {interval, select,\n month {وإدارة أو إلغاء اشتراكك}\n year {وإدارة أو إلغاء اشتراكك}\n other {وإدارة التبرعات المستقبلية}\n} في أي وقت باستخدام هذا الرابط:\n\n{manageUrl}\n\nدعمك يساعد على استمرار {product_name}. شكرًا لك!\n\n– فريق {product_name}"
"body": "مرحبًا،\n\nشكرًا لك على تبرعك لـ{product_name}! لقد تم {interval, select,\n month {إعداد تبرعك المتكرر}\n year {إعداد تبرعك المتكرر}\n other {معالجة تبرعك لمرة واحدة}\n} بنجاح.\n\nتفاصيل التبرع:\nالمبلغ: {amount} {interval, select,\n month {شهريًا}\n year {سنويًا}\n other {}\n}\n\nسترسل لك Stripe إيصالًا منفصلًا عبر البريد الإلكتروني مع فاتورتك بصيغة PDF قريبًا. يتضمن هذا جميع تفاصيل الدفع ويمكن استخدامه لأغراض ضريبية.\n\nيمكنك عرض سجل تبرعاتك، وتنزيل الفواتير، {interval, select,\n month {وإدارة أو إلغاء اشتراكك}\n year {وإدارة أو إلغاء اشتراكك}\n other {وإدارة التبرعات المستقبلية}\n} في أي وقت باستخدام هذا الرابط:\n\n{manageUrl}\n\nدعمك يساعد على استمرار {product_name}. شكرًا لك!\n\n– فريق {product_name}"
},
"donation_magic_link": {
"subject": "إدارة تبرعاتك لـ{product_name}",
@@ -17,7 +17,7 @@ const EMAIL_I18N_BG_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Благодарим ти за дарението към {product_name}",
"body": "Здравей,\n\nБлагодарим ти за дарението към {product_name}! Твоето {interval, select,\n month {периодично дарение}\n year {периодично дарение}\n other {еднократно дарение}\n} беше {interval, select,\n month {настроено}\n year {настроено}\n other {обработено}\n} успешно.\n\nПодробности за дарението:\nСума: {amount} {currency} {interval, select,\n month {на месец}\n year {на година}\n other {}\n}\n\nСкоро Stripe ще ти изпрати по имейл отделна разписка с PDF на фактурата ти. Тя включва всички данни за плащане и може да се използва за данъчни цели.\n\nМожеш да преглеждаш историята на даренията си, да изтегляш фактури {interval, select,\n month {и да управляваш или анулираш абонамента си}\n year {и да управляваш или анулираш абонамента си}\n other {и да управляваш бъдещи дарения}\n} по всяко време, като използваш този линк:\n\n{manageUrl}\n\nТвоята подкрепа помага на {product_name} да продължи да работи. Благодарим ти!\n\n– Екип на {product_name}"
"body": "Здравей,\n\nБлагодарим ти за дарението към {product_name}! Твоето {interval, select,\n month {периодично дарение}\n year {периодично дарение}\n other {еднократно дарение}\n} беше {interval, select,\n month {настроено}\n year {настроено}\n other {обработено}\n} успешно.\n\nПодробности за дарението:\nСума: {amount} {interval, select,\n month {на месец}\n year {на година}\n other {}\n}\n\nСкоро Stripe ще ти изпрати по имейл отделна разписка с PDF на фактурата ти. Тя включва всички данни за плащане и може да се използва за данъчни цели.\n\nМожеш да преглеждаш историята на даренията си, да изтегляш фактури {interval, select,\n month {и да управляваш или анулираш абонамента си}\n year {и да управляваш или анулираш абонамента си}\n other {и да управляваш бъдещи дарения}\n} по всяко време, като използваш този линк:\n\n{manageUrl}\n\nТвоята подкрепа помага на {product_name} да продължи да работи. Благодарим ти!\n\n– Екип на {product_name}"
},
"donation_magic_link": {
"subject": "Управлявай даренията си за {product_name}",
@@ -17,7 +17,7 @@ const EMAIL_I18N_CS_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Děkujeme za váš dar pro {product_name}",
"body": "Dobrý den,\n\nDěkujeme za váš dar pro {product_name}! Váš {interval, select,\n month {opakovaný dar}\n year {opakovaný dar}\n other {jednorázový dar}\n} byl úspěšně {interval, select,\n month {nastaven}\n year {nastaven}\n other {zpracován}\n}.\n\nPodrobnosti o daru:\nČástka: {amount} {currency} {interval, select,\n month {měsíčně}\n year {ročně}\n other {}\n}\n\nStripe vám brzy pošle samostatné potvrzení platby s fakturou v PDF. Obsahuje všechny platební údaje a lze ho použít pro daňové účely.\n\nPomocí tohoto odkazu si můžete kdykoli prohlédnout historii svých darů, stáhnout faktury {interval, select,\n month {a spravovat nebo zrušit předplatné}\n year {a spravovat nebo zrušit předplatné}\n other {a spravovat budoucí dary}\n}:\n\n{manageUrl}\n\nVaše podpora pomáhá udržovat {product_name} v chodu. Děkujeme!\n\n– Tým {product_name}"
"body": "Dobrý den,\n\nDěkujeme za váš dar pro {product_name}! Váš {interval, select,\n month {opakovaný dar}\n year {opakovaný dar}\n other {jednorázový dar}\n} byl úspěšně {interval, select,\n month {nastaven}\n year {nastaven}\n other {zpracován}\n}.\n\nPodrobnosti o daru:\nČástka: {amount} {interval, select,\n month {měsíčně}\n year {ročně}\n other {}\n}\n\nStripe vám brzy pošle samostatné potvrzení platby s fakturou v PDF. Obsahuje všechny platební údaje a lze ho použít pro daňové účely.\n\nPomocí tohoto odkazu si můžete kdykoli prohlédnout historii svých darů, stáhnout faktury {interval, select,\n month {a spravovat nebo zrušit předplatné}\n year {a spravovat nebo zrušit předplatné}\n other {a spravovat budoucí dary}\n}:\n\n{manageUrl}\n\nVaše podpora pomáhá udržovat {product_name} v chodu. Děkujeme!\n\n– Tým {product_name}"
},
"donation_magic_link": {
"subject": "Spravujte své dary pro {product_name}",
@@ -17,7 +17,7 @@ const EMAIL_I18N_DA_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Tak for din {product_name}-donation",
"body": "Hej,\n\nTak for din donation til {product_name}! Din {interval, select,\n month {løbende donation}\n year {løbende donation}\n other {engangsdonation}\n} er blevet {interval, select,\n month {oprettet}\n year {oprettet}\n other {gennemført}\n}.\n\nDonationsdetaljer:\nBeløb: {amount} {currency} {interval, select,\n month {pr. måned}\n year {pr. år}\n other {}\n}\n\nStripe sender dig en separat kvittering med din faktura som PDF inden længe. Kvitteringen indeholder alle betalingsdetaljer og kan bruges til skatteformål.\n\nDu kan til enhver tid se din donationshistorik, downloade fakturaer {interval, select,\n month {og administrere eller opsige dit abonnement}\n year {og administrere eller opsige dit abonnement}\n other {og administrere fremtidige donationer}\n} ved at bruge dette link:\n\n{manageUrl}\n\nDin støtte hjælper med at holde {product_name} kørende. Tak!\n\n– Teamet bag {product_name}"
"body": "Hej,\n\nTak for din donation til {product_name}! Din {interval, select,\n month {løbende donation}\n year {løbende donation}\n other {engangsdonation}\n} er blevet {interval, select,\n month {oprettet}\n year {oprettet}\n other {gennemført}\n}.\n\nDonationsdetaljer:\nBeløb: {amount} {interval, select,\n month {pr. måned}\n year {pr. år}\n other {}\n}\n\nStripe sender dig en separat kvittering med din faktura som PDF inden længe. Kvitteringen indeholder alle betalingsdetaljer og kan bruges til skatteformål.\n\nDu kan til enhver tid se din donationshistorik, downloade fakturaer {interval, select,\n month {og administrere eller opsige dit abonnement}\n year {og administrere eller opsige dit abonnement}\n other {og administrere fremtidige donationer}\n} ved at bruge dette link:\n\n{manageUrl}\n\nDin støtte hjælper med at holde {product_name} kørende. Tak!\n\n– Teamet bag {product_name}"
},
"donation_magic_link": {
"subject": "Administrer dine {product_name}-donationer",
@@ -17,7 +17,7 @@ const EMAIL_I18N_DE_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Vielen Dank für deine {product_name}-Spende",
"body": "Hallo,\n\nvielen Dank für deine Spende an {product_name}! Deine {interval, select,\n month {wiederkehrende Spende}\n year {wiederkehrende Spende}\n other {einmalige Spende}\n} wurde erfolgreich {interval, select,\n month {eingerichtet}\n year {eingerichtet}\n other {verarbeitet}\n}.\n\nDetails zur Spende:\nBetrag: {amount} {currency} {interval, select,\n month {pro Monat}\n year {pro Jahr}\n other {}\n}\n\nStripe sendet dir in Kürze eine separate Quittung mit deiner Rechnungs-PDF per E-Mail. Diese enthält alle Zahlungsdetails und kann für Steuerzwecke verwendet werden.\n\nÜber diesen Link kannst du jederzeit deine Spendenübersicht aufrufen, Rechnungen herunterladen {interval, select,\n month {und dein Abonnement verwalten oder kündigen}\n year {und dein Abonnement verwalten oder kündigen}\n other {und zukünftige Spenden verwalten}\n}:\n\n{manageUrl}\n\nDeine Unterstützung hilft, {product_name} am Laufen zu halten. Vielen Dank!\n\n– {product_name}-Team"
"body": "Hallo,\n\nvielen Dank für deine Spende an {product_name}! Deine {interval, select,\n month {wiederkehrende Spende}\n year {wiederkehrende Spende}\n other {einmalige Spende}\n} wurde erfolgreich {interval, select,\n month {eingerichtet}\n year {eingerichtet}\n other {verarbeitet}\n}.\n\nDetails zur Spende:\nBetrag: {amount} {interval, select,\n month {pro Monat}\n year {pro Jahr}\n other {}\n}\n\nStripe sendet dir in Kürze eine separate Quittung mit deiner Rechnungs-PDF per E-Mail. Diese enthält alle Zahlungsdetails und kann für Steuerzwecke verwendet werden.\n\nÜber diesen Link kannst du jederzeit deine Spendenübersicht aufrufen, Rechnungen herunterladen {interval, select,\n month {und dein Abonnement verwalten oder kündigen}\n year {und dein Abonnement verwalten oder kündigen}\n other {und zukünftige Spenden verwalten}\n}:\n\n{manageUrl}\n\nDeine Unterstützung hilft, {product_name} am Laufen zu halten. Vielen Dank!\n\n– {product_name}-Team"
},
"donation_magic_link": {
"subject": "Verwalte deine {product_name}-Spenden",
@@ -17,7 +17,7 @@ const EMAIL_I18N_EL_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Ευχαριστούμε για τη δωρεά σου στο {product_name}",
"body": "Γεια σου,\n\nΕυχαριστούμε για τη δωρεά σου στο {product_name}! Η {interval, select,\n month {επαναλαμβανόμενη δωρεά}\n year {επαναλαμβανόμενη δωρεά}\n other {εφάπαξ δωρεά}\n} σου έχει {interval, select,\n month {ρυθμιστεί}\n year {ρυθμιστεί}\n other {ολοκληρωθεί}\n} με επιτυχία.\n\nΛεπτομέρειες δωρεάς:\nΠοσό: {amount} {currency} {interval, select,\n month {ανά μήνα}\n year {ανά έτος}\n other {}\n}\n\nΗ Stripe θα σου στείλει σύντομα ξεχωριστή απόδειξη με το PDF του τιμολογίου σου. Περιλαμβάνει όλες τις λεπτομέρειες πληρωμής και μπορεί να χρησιμοποιηθεί για φορολογικούς σκοπούς.\n\nΜπορείς να δεις το ιστορικό των δωρεών σου, να κατεβάσεις τιμολόγια, {interval, select,\n month {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n year {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n other {και να διαχειριστείς μελλοντικές δωρεές}\n} ανά πάσα στιγμή χρησιμοποιώντας αυτόν τον σύνδεσμο:\n\n{manageUrl}\n\nΗ υποστήριξή σου βοηθάει το {product_name} να συνεχίσει να λειτουργεί. Ευχαριστούμε!\n\n– Η ομάδα του {product_name}"
"body": "Γεια σου,\n\nΕυχαριστούμε για τη δωρεά σου στο {product_name}! Η {interval, select,\n month {επαναλαμβανόμενη δωρεά}\n year {επαναλαμβανόμενη δωρεά}\n other {εφάπαξ δωρεά}\n} σου έχει {interval, select,\n month {ρυθμιστεί}\n year {ρυθμιστεί}\n other {ολοκληρωθεί}\n} με επιτυχία.\n\nΛεπτομέρειες δωρεάς:\nΠοσό: {amount} {interval, select,\n month {ανά μήνα}\n year {ανά έτος}\n other {}\n}\n\nΗ Stripe θα σου στείλει σύντομα ξεχωριστή απόδειξη με το PDF του τιμολογίου σου. Περιλαμβάνει όλες τις λεπτομέρειες πληρωμής και μπορεί να χρησιμοποιηθεί για φορολογικούς σκοπούς.\n\nΜπορείς να δεις το ιστορικό των δωρεών σου, να κατεβάσεις τιμολόγια, {interval, select,\n month {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n year {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n other {και να διαχειριστείς μελλοντικές δωρεές}\n} ανά πάσα στιγμή χρησιμοποιώντας αυτόν τον σύνδεσμο:\n\n{manageUrl}\n\nΗ υποστήριξή σου βοηθάει το {product_name} να συνεχίσει να λειτουργεί. Ευχαριστούμε!\n\n– Η ομάδα του {product_name}"
},
"donation_magic_link": {
"subject": "Διαχειρίσου τις δωρεές σου στο {product_name}",
@@ -17,7 +17,7 @@ const EMAIL_I18N_EN_GB_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Thank you for your {product_name} donation",
"body": "Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {currency} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team"
"body": "Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team"
},
"donation_magic_link": {
"subject": "Manage your {product_name} donations",
@@ -17,7 +17,7 @@ const EMAIL_I18N_ES_419_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Gracias por tu donación a {product_name}",
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} ha sido {interval, select,\n month {configurada}\n year {configurada}\n other {procesada}\n} con éxito.\n\nDetalles de la donación:\nMonto: {amount} {currency} {interval, select,\n month {por mes}\n year {por año}\n other {}\n}\n\nEn breve, Stripe te enviará por separado un correo electrónico con el recibo y la factura en PDF. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y administrar o cancelar tu suscripción}\n year {y administrar o cancelar tu suscripción}\n other {y administrar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} ha sido {interval, select,\n month {configurada}\n year {configurada}\n other {procesada}\n} con éxito.\n\nDetalles de la donación:\nMonto: {amount} {interval, select,\n month {por mes}\n year {por año}\n other {}\n}\n\nEn breve, Stripe te enviará por separado un correo electrónico con el recibo y la factura en PDF. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y administrar o cancelar tu suscripción}\n year {y administrar o cancelar tu suscripción}\n other {y administrar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
},
"donation_magic_link": {
"subject": "Administra tus donaciones a {product_name}",
@@ -17,7 +17,7 @@ const EMAIL_I18N_ES_ES_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Gracias por tu donación a {product_name}",
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} se ha {interval, select,\n month {configurado}\n year {configurado}\n other {procesado}\n} correctamente.\n\nDetalles de la donación:\nImporte: {amount} {currency} {interval, select,\n month {al mes}\n year {al año}\n other {}\n}\n\nEn breve, Stripe te enviará por separado un correo electrónico con el recibo y la factura en PDF. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y gestionar o cancelar tu suscripción}\n year {y gestionar o cancelar tu suscripción}\n other {y gestionar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} se ha {interval, select,\n month {configurado}\n year {configurado}\n other {procesado}\n} correctamente.\n\nDetalles de la donación:\nImporte: {amount} {interval, select,\n month {al mes}\n year {al año}\n other {}\n}\n\nEn breve, Stripe te enviará por separado un correo electrónico con el recibo y la factura en PDF. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y gestionar o cancelar tu suscripción}\n year {y gestionar o cancelar tu suscripción}\n other {y gestionar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
},
"donation_magic_link": {
"subject": "Gestiona tus donaciones a {product_name}",
@@ -17,7 +17,7 @@ const EMAIL_I18N_FI_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Kiitos {product_name}-lahjoituksestasi",
"body": "Hei,\n\nKiitos {product_name}-lahjoituksestasi! {interval, select,\n month {Toistuva lahjoituksesi}\n year {Toistuva lahjoituksesi}\n other {Kertalahjoituksesi}\n} on {interval, select,\n month {käynnistetty}\n year {käynnistetty}\n other {käsitelty}\n} onnistuneesti.\n\nLahjoituksen tiedot:\nSumma: {amount} {currency} {interval, select,\n month {kuukaudessa}\n year {vuodessa}\n other {}\n}\n\nStripe lähettää sinulle pian sähköpostitse erillisen kuitin sekä PDF-laskun. Niissä ovat kaikki maksutiedot, ja niitä voi käyttää verotuksessa.\n\nVoit tarkastella lahjoitushistoriaasi, ladata laskuja {interval, select,\n month {ja hallinnoida tai peruuttaa tilaustasi}\n year {ja hallinnoida tai peruuttaa tilaustasi}\n other {ja hallita tulevia lahjoituksia}\n} milloin tahansa käyttämällä tätä linkkiä:\n\n{manageUrl}\n\nTukesi auttaa pitämään {product_name}-palvelun toiminnassa. Kiitos!\n\n– {product_name}-tiimi"
"body": "Hei,\n\nKiitos {product_name}-lahjoituksestasi! {interval, select,\n month {Toistuva lahjoituksesi}\n year {Toistuva lahjoituksesi}\n other {Kertalahjoituksesi}\n} on {interval, select,\n month {käynnistetty}\n year {käynnistetty}\n other {käsitelty}\n} onnistuneesti.\n\nLahjoituksen tiedot:\nSumma: {amount} {interval, select,\n month {kuukaudessa}\n year {vuodessa}\n other {}\n}\n\nStripe lähettää sinulle pian sähköpostitse erillisen kuitin sekä PDF-laskun. Niissä ovat kaikki maksutiedot, ja niitä voi käyttää verotuksessa.\n\nVoit tarkastella lahjoitushistoriaasi, ladata laskuja {interval, select,\n month {ja hallinnoida tai peruuttaa tilaustasi}\n year {ja hallinnoida tai peruuttaa tilaustasi}\n other {ja hallita tulevia lahjoituksia}\n} milloin tahansa käyttämällä tätä linkkiä:\n\n{manageUrl}\n\nTukesi auttaa pitämään {product_name}-palvelun toiminnassa. Kiitos!\n\n– {product_name}-tiimi"
},
"donation_magic_link": {
"subject": "Hallitse {product_name}-lahjoituksiasi",
@@ -17,7 +17,7 @@ const EMAIL_I18N_FR_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Merci pour votre don à {product_name}",
"body": "Bonjour,\n\nMerci pour votre don à {product_name} ! {interval, select, month {Votre don récurrent a bien été mis en place.} year {Votre don récurrent a bien été mis en place.} other {Votre don ponctuel a bien été traité.}}\n\nDétails du don :\nMontant : {amount} {currency} {interval, select, month {par mois} year {par an} other {}}\n\nStripe vous enverra bientôt un reçu séparé accompagné de votre facture PDF. Ce document contient tous les détails du paiement et peut servir de justificatif fiscal.\n\nVous pouvez consulter l’historique de vos dons, télécharger des factures {interval, select, month {et gérer ou résilier votre abonnement} year {et gérer ou résilier votre abonnement} other {et gérer vos futurs dons}} à tout moment avec ce lien :\n\n{manageUrl}\n\nVotre soutien contribue au fonctionnement de {product_name}. Merci !\n\n– L’équipe {product_name}"
"body": "Bonjour,\n\nMerci pour votre don à {product_name} ! {interval, select, month {Votre don récurrent a bien été mis en place.} year {Votre don récurrent a bien été mis en place.} other {Votre don ponctuel a bien été traité.}}\n\nDétails du don :\nMontant : {amount} {interval, select, month {par mois} year {par an} other {}}\n\nStripe vous enverra bientôt un reçu séparé accompagné de votre facture PDF. Ce document contient tous les détails du paiement et peut servir de justificatif fiscal.\n\nVous pouvez consulter l’historique de vos dons, télécharger des factures {interval, select, month {et gérer ou résilier votre abonnement} year {et gérer ou résilier votre abonnement} other {et gérer vos futurs dons}} à tout moment avec ce lien :\n\n{manageUrl}\n\nVotre soutien contribue au fonctionnement de {product_name}. Merci !\n\n– L’équipe {product_name}"
},
"donation_magic_link": {
"subject": "Gérer vos dons à {product_name}",
@@ -17,7 +17,7 @@ const EMAIL_I18N_HE_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "תודה על תרומתכם ל-{product_name}",
"body": "שלום,\n\nתודה על תרומתכם ל-{product_name}! ה{interval, select,\n month {תרומה החוזרת}\n year {תרומה החוזרת}\n other {תרומה החד-פעמית}\n} שלכם {interval, select,\n month {הוגדרה}\n year {הוגדרה}\n other {בוצעה}\n} בהצלחה.\n\nפרטי התרומה:\nסכום: {amount} {currency} {interval, select,\n month {לחודש}\n year {לשנה}\n other {}\n}\n\nStripe ישלח לכם בקרוב קבלה נפרדת עם קובץ PDF של החשבונית. הקבלה תכלול את כל פרטי התשלום וניתן יהיה להשתמש בה למטרות מס.\n\nאתם יכולים לצפות בהיסטוריית התרומות שלכם, להוריד חשבוניות, {interval, select,\n month {ולנהל או לבטל את המנוי}\n year {ולנהל או לבטל את המנוי}\n other {ולנהל תרומות עתידיות}\n} בכל עת באמצעות קישור זה:\n\n{manageUrl}\n\nהתמיכה שלכם עוזרת לשמור על פעילות {product_name}. תודה!\n\nצוות {product_name}"
"body": "שלום,\n\nתודה על תרומתכם ל-{product_name}! ה{interval, select,\n month {תרומה החוזרת}\n year {תרומה החוזרת}\n other {תרומה החד-פעמית}\n} שלכם {interval, select,\n month {הוגדרה}\n year {הוגדרה}\n other {בוצעה}\n} בהצלחה.\n\nפרטי התרומה:\nסכום: {amount} {interval, select,\n month {לחודש}\n year {לשנה}\n other {}\n}\n\nStripe ישלח לכם בקרוב קבלה נפרדת עם קובץ PDF של החשבונית. הקבלה תכלול את כל פרטי התשלום וניתן יהיה להשתמש בה למטרות מס.\n\nאתם יכולים לצפות בהיסטוריית התרומות שלכם, להוריד חשבוניות, {interval, select,\n month {ולנהל או לבטל את המנוי}\n year {ולנהל או לבטל את המנוי}\n other {ולנהל תרומות עתידיות}\n} בכל עת באמצעות קישור זה:\n\n{manageUrl}\n\nהתמיכה שלכם עוזרת לשמור על פעילות {product_name}. תודה!\n\nצוות {product_name}"
},
"donation_magic_link": {
"subject": "ניהול התרומות שלכם ל-{product_name}",
@@ -17,7 +17,7 @@ const EMAIL_I18N_HI_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "आपके {product_name} डोनेशन के लिए धन्यवाद",
"body": "नमस्ते,\n\n{product_name} को आपके डोनेशन के लिए धन्यवाद! आपका {interval, select,\n month {रिकरिंग डोनेशन}\n year {रिकरिंग डोनेशन}\n other {वन-टाइम डोनेशन}\n} सफलतापूर्वक {interval, select,\n month {सेट अप हो गया है}\n year {सेट अप हो गया है}\n other {प्रोसेस हो गया है}\n}।\n\nडोनेशन का विवरण:\nराशि: {amount} {currency} {interval, select,\n month {प्रति माह}\n year {प्रति वर्ष}\n other {}\n}\n\nStripe आपको जल्द ही आपके इनवॉइस PDF के साथ एक अलग रसीद ईमेल करेगा। इसमें पेमेंट की सारी जानकारी शामिल है और इसे टैक्स के लिए इस्तेमाल किया जा सकता है।\n\nआप इस लिंक से किसी भी समय अपने डोनेशन का इतिहास देख सकते हैं, इनवॉइस डाउनलोड कर सकते हैं, {interval, select,\n month {और अपना सब्सक्रिप्शन मैनेज या कैंसल कर सकते हैं}\n year {और अपना सब्सक्रिप्शन मैनेज या कैंसल कर सकते हैं}\n other {और आगे के डोनेशन मैनेज कर सकते हैं}\n}।\n\n{manageUrl}\n\nआपका सपोर्ट {product_name} को चालू रखने में मदद करता है। धन्यवाद!\n\n– {product_name} टीम"
"body": "नमस्ते,\n\n{product_name} को आपके डोनेशन के लिए धन्यवाद! आपका {interval, select,\n month {रिकरिंग डोनेशन}\n year {रिकरिंग डोनेशन}\n other {वन-टाइम डोनेशन}\n} सफलतापूर्वक {interval, select,\n month {सेट अप हो गया है}\n year {सेट अप हो गया है}\n other {प्रोसेस हो गया है}\n}।\n\nडोनेशन का विवरण:\nराशि: {amount} {interval, select,\n month {प्रति माह}\n year {प्रति वर्ष}\n other {}\n}\n\nStripe आपको जल्द ही आपके इनवॉइस PDF के साथ एक अलग रसीद ईमेल करेगा। इसमें पेमेंट की सारी जानकारी शामिल है और इसे टैक्स के लिए इस्तेमाल किया जा सकता है।\n\nआप इस लिंक से किसी भी समय अपने डोनेशन का इतिहास देख सकते हैं, इनवॉइस डाउनलोड कर सकते हैं, {interval, select,\n month {और अपना सब्सक्रिप्शन मैनेज या कैंसल कर सकते हैं}\n year {और अपना सब्सक्रिप्शन मैनेज या कैंसल कर सकते हैं}\n other {और आगे के डोनेशन मैनेज कर सकते हैं}\n}।\n\n{manageUrl}\n\nआपका सपोर्ट {product_name} को चालू रखने में मदद करता है। धन्यवाद!\n\n– {product_name} टीम"
},
"donation_magic_link": {
"subject": "अपने {product_name} डोनेशन मैनेज करें",
@@ -17,7 +17,7 @@ const EMAIL_I18N_HR_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Hvala ti na donaciji za {product_name}",
"body": "Pozdrav,\n\nHvala ti na donaciji za {product_name}! Tvoja {interval, select,\n month {redovita donacija}\n year {redovita donacija}\n other {jednokratna donacija}\n} uspješno je {interval, select,\n month {postavljena}\n year {postavljena}\n other {obrađena}\n}.\n\nDetalji donacije:\nIznos: {amount} {currency} {interval, select,\n month {mjesečno}\n year {godišnje}\n other {}\n}\n\nStripe će ti uskoro poslati zasebnu potvrdu s PDF-om računa. Ona sadrži sve detalje plaćanja i možeš je koristiti u porezne svrhe.\n\nMožeš pregledati povijest donacija, preuzeti račune {interval, select,\n month {i upravljati pretplatom ili je otkazati}\n year {i upravljati pretplatom ili je otkazati}\n other {i upravljati budućim donacijama}\n} u bilo kojem trenutku putem ove poveznice:\n\n{manageUrl}\n\nTvoja podrška pomaže da {product_name} nastavi raditi. Hvala ti!\n\n– Tim {product_name}a"
"body": "Pozdrav,\n\nHvala ti na donaciji za {product_name}! Tvoja {interval, select,\n month {redovita donacija}\n year {redovita donacija}\n other {jednokratna donacija}\n} uspješno je {interval, select,\n month {postavljena}\n year {postavljena}\n other {obrađena}\n}.\n\nDetalji donacije:\nIznos: {amount} {interval, select,\n month {mjesečno}\n year {godišnje}\n other {}\n}\n\nStripe će ti uskoro poslati zasebnu potvrdu s PDF-om računa. Ona sadrži sve detalje plaćanja i možeš je koristiti u porezne svrhe.\n\nMožeš pregledati povijest donacija, preuzeti račune {interval, select,\n month {i upravljati pretplatom ili je otkazati}\n year {i upravljati pretplatom ili je otkazati}\n other {i upravljati budućim donacijama}\n} u bilo kojem trenutku putem ove poveznice:\n\n{manageUrl}\n\nTvoja podrška pomaže da {product_name} nastavi raditi. Hvala ti!\n\n– Tim {product_name}a"
},
"donation_magic_link": {
"subject": "Upravljanje donacijama za {product_name}",
@@ -17,7 +17,7 @@ const EMAIL_I18N_HU_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Köszönjük, hogy adományoddal támogatod a {product_name} működését",
"body": "Szia!\n\nKöszönjük, hogy adományoddal támogatod a {product_name} működését! {interval, select,\n month {Sikeresen beállítottuk a rendszeres adományodat.}\n year {Sikeresen beállítottuk a rendszeres adományodat.}\n other {Sikeresen feldolgoztuk az egyszeri adományodat.}\n}\n\nAz adomány részletei:\nÖsszeg: {amount} {currency} {interval, select,\n month {havonta}\n year {évente}\n other {}\n}\n\nA Stripe hamarosan külön e-mailben küld nyugtát és egy PDF-számlát. Ezek tartalmazzák az összes fizetési adatot, és adózási célokra is használhatók.\n\nAz alábbi linken bármikor megtekintheted korábbi adományaidat, letöltheted a számlákat, {interval, select,\n month {valamint kezelheted vagy lemondhatod az előfizetésedet}\n year {valamint kezelheted vagy lemondhatod az előfizetésedet}\n other {valamint kezelheted a jövőbeli adományaidat}\n}:\n\n{manageUrl}\n\nTámogatásod segít fenntartani a {product_name} működését. Köszönjük!\n\n– {product_name} csapata"
"body": "Szia!\n\nKöszönjük, hogy adományoddal támogatod a {product_name} működését! {interval, select,\n month {Sikeresen beállítottuk a rendszeres adományodat.}\n year {Sikeresen beállítottuk a rendszeres adományodat.}\n other {Sikeresen feldolgoztuk az egyszeri adományodat.}\n}\n\nAz adomány részletei:\nÖsszeg: {amount} {interval, select,\n month {havonta}\n year {évente}\n other {}\n}\n\nA Stripe hamarosan külön e-mailben küld nyugtát és egy PDF-számlát. Ezek tartalmazzák az összes fizetési adatot, és adózási célokra is használhatók.\n\nAz alábbi linken bármikor megtekintheted korábbi adományaidat, letöltheted a számlákat, {interval, select,\n month {valamint kezelheted vagy lemondhatod az előfizetésedet}\n year {valamint kezelheted vagy lemondhatod az előfizetésedet}\n other {valamint kezelheted a jövőbeli adományaidat}\n}:\n\n{manageUrl}\n\nTámogatásod segít fenntartani a {product_name} működését. Köszönjük!\n\n– {product_name} csapata"
},
"donation_magic_link": {
"subject": "A {product_name} működését támogató adományaid kezelése",
@@ -17,7 +17,7 @@ const EMAIL_I18N_ID_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Terima kasih atas donasimu untuk {product_name}",
"body": "Halo,\n\nTerima kasih atas donasimu untuk {product_name}! Donasi {interval, select,\n month {berulang}\n year {berulang}\n other {satu kali}\n} kamu telah berhasil {interval, select,\n month {diatur}\n year {diatur}\n other {diproses}\n}.\n\nDetail donasi:\nJumlah: {amount} {currency} {interval, select,\n month {per bulan}\n year {per tahun}\n other {}\n}\n\nStripe akan segera mengirimi kamu email tanda terima terpisah beserta PDF fakturmu. Ini mencakup semua detail pembayaran dan bisa dipakai untuk keperluan pajak.\n\nKamu bisa melihat riwayat donasimu, mengunduh faktur, {interval, select,\n month {dan mengelola atau membatalkan langgananmu}\n year {dan mengelola atau membatalkan langgananmu}\n other {dan mengelola donasi di masa mendatang}\n} kapan saja lewat tautan ini:\n\n{manageUrl}\n\nDukunganmu membantu {product_name} tetap berjalan. Terima kasih!\n\n– Tim {product_name}"
"body": "Halo,\n\nTerima kasih atas donasimu untuk {product_name}! Donasi {interval, select,\n month {berulang}\n year {berulang}\n other {satu kali}\n} kamu telah berhasil {interval, select,\n month {diatur}\n year {diatur}\n other {diproses}\n}.\n\nDetail donasi:\nJumlah: {amount} {interval, select,\n month {per bulan}\n year {per tahun}\n other {}\n}\n\nStripe akan segera mengirimi kamu email tanda terima terpisah beserta PDF fakturmu. Ini mencakup semua detail pembayaran dan bisa dipakai untuk keperluan pajak.\n\nKamu bisa melihat riwayat donasimu, mengunduh faktur, {interval, select,\n month {dan mengelola atau membatalkan langgananmu}\n year {dan mengelola atau membatalkan langgananmu}\n other {dan mengelola donasi di masa mendatang}\n} kapan saja lewat tautan ini:\n\n{manageUrl}\n\nDukunganmu membantu {product_name} tetap berjalan. Terima kasih!\n\n– Tim {product_name}"
},
"donation_magic_link": {
"subject": "Kelola donasimu untuk {product_name}",
@@ -17,7 +17,7 @@ const EMAIL_I18N_IT_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Grazie per la tua donazione a {product_name}",
"body": "Ciao,\n\nGrazie per la tua donazione a {product_name}! La tua {interval, select,\n month {donazione ricorrente}\n year {donazione ricorrente}\n other {donazione una tantum}\n} è stata {interval, select,\n month {attivata}\n year {attivata}\n other {elaborata}\n} con successo.\n\nDettagli della donazione:\nImporto: {amount} {currency} {interval, select,\n month {al mese}\n year {all'anno}\n other {}\n}\n\nStripe ti invierà a breve una ricevuta separata con la tua fattura in PDF. Questa include tutti i dettagli del pagamento e può essere utilizzata a fini fiscali.\n\nPuoi visualizzare la cronologia delle tue donazioni, scaricare le fatture {interval, select,\n month {e gestire o disdire il tuo abbonamento}\n year {e gestire o disdire il tuo abbonamento}\n other {e gestire le future donazioni}\n} in qualsiasi momento utilizzando questo link:\n\n{manageUrl}\n\nIl tuo supporto aiuta a mantenere {product_name} in funzione. Grazie!\n\n– Il team di {product_name}"
"body": "Ciao,\n\nGrazie per la tua donazione a {product_name}! La tua {interval, select,\n month {donazione ricorrente}\n year {donazione ricorrente}\n other {donazione una tantum}\n} è stata {interval, select,\n month {attivata}\n year {attivata}\n other {elaborata}\n} con successo.\n\nDettagli della donazione:\nImporto: {amount} {interval, select,\n month {al mese}\n year {all'anno}\n other {}\n}\n\nStripe ti invierà a breve una ricevuta separata con la tua fattura in PDF. Questa include tutti i dettagli del pagamento e può essere utilizzata a fini fiscali.\n\nPuoi visualizzare la cronologia delle tue donazioni, scaricare le fatture {interval, select,\n month {e gestire o disdire il tuo abbonamento}\n year {e gestire o disdire il tuo abbonamento}\n other {e gestire le future donazioni}\n} in qualsiasi momento utilizzando questo link:\n\n{manageUrl}\n\nIl tuo supporto aiuta a mantenere {product_name} in funzione. Grazie!\n\n– Il team di {product_name}"
},
"donation_magic_link": {
"subject": "Gestisci le tue donazioni a {product_name}",
@@ -17,7 +17,7 @@ const EMAIL_I18N_JA_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "{product_name}へのご寄付ありがとうございます",
"body": "こんにちは。\n\n{product_name}へのご寄付ありがとうございます。{interval, select,\n month {定期寄付}\n year {定期寄付}\n other {1回のみの寄付}\n}の{interval, select,\n month {設定}\n year {設定}\n other {処理}\n}が完了しました。\n\n寄付の詳細:\n金額: {amount} {currency}{interval, select,\n month {(毎月)}\n year {(毎年)}\n other {}\n}\n\nStripeから、請求書PDF付きの領収書が別途メールで送信されます。これにはすべての支払い詳細が含まれており、税務目的にご利用いただけます。\n\n以下のリンクから、いつでも寄付履歴の表示、請求書のダウンロード、{interval, select,\n month {およびサブスクリプションの管理またはキャンセル}\n year {およびサブスクリプションの管理またはキャンセル}\n other {および今後の寄付の管理}\n}が可能です。\n\n{manageUrl}\n\n皆様のご支援が{product_name}の運営を支えています。ありがとうございます。\n\n– {product_name}チーム"
"body": "こんにちは。\n\n{product_name}へのご寄付ありがとうございます。{interval, select,\n month {定期寄付}\n year {定期寄付}\n other {1回のみの寄付}\n}の{interval, select,\n month {設定}\n year {設定}\n other {処理}\n}が完了しました。\n\n寄付の詳細:\n金額: {amount}{interval, select,\n month {(毎月)}\n year {(毎年)}\n other {}\n}\n\nStripeから、請求書PDF付きの領収書が別途メールで送信されます。これにはすべての支払い詳細が含まれており、税務目的にご利用いただけます。\n\n以下のリンクから、いつでも寄付履歴の表示、請求書のダウンロード、{interval, select,\n month {およびサブスクリプションの管理またはキャンセル}\n year {およびサブスクリプションの管理またはキャンセル}\n other {および今後の寄付の管理}\n}が可能です。\n\n{manageUrl}\n\n皆様のご支援が{product_name}の運営を支えています。ありがとうございます。\n\n– {product_name}チーム"
},
"donation_magic_link": {
"subject": "{product_name}への寄付を管理",
@@ -17,7 +17,7 @@ const EMAIL_I18N_KO_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "{product_name} 후원에 감사드려요",
"body": "안녕하세요,\n\n{product_name}에 후원해 주셔서 감사해요! 회원님의 {interval, select,\n month {정기 후원}\n year {정기 후원}\n other {일회성 후원}\n}이 성공적으로 {interval, select,\n month {설정되었어요}\n year {설정되었어요}\n other {처리되었어요}\n}.\n\n후원 상세 정보:\n금액: {amount} {currency} {interval, select,\n month {매월}\n year {매년}\n other {}\n}\n\nStripe에서 곧 인보이스 PDF가 포함된 별도의 영수증을 이메일로 보내드릴 예정이에요. 이 영수증에는 모든 결제 세부 정보가 포함되어 있으며, 세금 목적으로 활용할 수 있어요.\n\n다음 링크를 통해 언제든지 후원 내역을 확인하고, 인보이스를 다운로드하고, {interval, select,\n month {구독을 관리하거나 취소할 수 있어요}\n year {구독을 관리하거나 취소할 수 있어요}\n other {향후 후원을 관리할 수 있어요}\n}:\n\n{manageUrl}\n\n회원님의 후원은 {product_name}를 계속 운영하는 데 큰 도움이 돼요. 진심으로 감사해요!\n\n– {product_name} 팀"
"body": "안녕하세요,\n\n{product_name}에 후원해 주셔서 감사해요! 회원님의 {interval, select,\n month {정기 후원}\n year {정기 후원}\n other {일회성 후원}\n}이 성공적으로 {interval, select,\n month {설정되었어요}\n year {설정되었어요}\n other {처리되었어요}\n}.\n\n후원 상세 정보:\n금액: {amount} {interval, select,\n month {매월}\n year {매년}\n other {}\n}\n\nStripe에서 곧 인보이스 PDF가 포함된 별도의 영수증을 이메일로 보내드릴 예정이에요. 이 영수증에는 모든 결제 세부 정보가 포함되어 있으며, 세금 목적으로 활용할 수 있어요.\n\n다음 링크를 통해 언제든지 후원 내역을 확인하고, 인보이스를 다운로드하고, {interval, select,\n month {구독을 관리하거나 취소할 수 있어요}\n year {구독을 관리하거나 취소할 수 있어요}\n other {향후 후원을 관리할 수 있어요}\n}:\n\n{manageUrl}\n\n회원님의 후원은 {product_name}를 계속 운영하는 데 큰 도움이 돼요. 진심으로 감사해요!\n\n– {product_name} 팀"
},
"donation_magic_link": {
"subject": "{product_name} 후원 내역 관리",
@@ -17,7 +17,7 @@ const EMAIL_I18N_LT_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Dėkojame už tavo {product_name} paramą",
"body": "Sveiki,\n\nDėkojame už tavo paramą {product_name}! Tavo {interval, select,\n month {pasikartojanti parama}\n year {pasikartojanti parama}\n other {vienkartinė parama}\n} sėkmingai {interval, select,\n month {nustatyta}\n year {nustatyta}\n other {apdorota}\n}.\n\nParamos informacija:\nSuma: {amount} {currency} {interval, select,\n month {per mėnesį}\n year {per metus}\n other {}\n}\n\nStripe netrukus atsiųs tau atskirą kvitą su sąskaitos faktūros PDF. Jame nurodyta visa mokėjimo informacija ir jį galima naudoti mokesčių tikslais.\n\nBet kuriuo metu šia nuoroda gali peržiūrėti savo paramos istoriją, atsisiųsti sąskaitas faktūras {interval, select,\n month {ir valdyti arba atšaukti savo prenumeratą}\n year {ir valdyti arba atšaukti savo prenumeratą}\n other {ir valdyti būsimą paramą}\n}:\n\n{manageUrl}\n\nTavo parama padeda {product_name} veikti. Ačiū!\n\n– {product_name} komanda"
"body": "Sveiki,\n\nDėkojame už tavo paramą {product_name}! Tavo {interval, select,\n month {pasikartojanti parama}\n year {pasikartojanti parama}\n other {vienkartinė parama}\n} sėkmingai {interval, select,\n month {nustatyta}\n year {nustatyta}\n other {apdorota}\n}.\n\nParamos informacija:\nSuma: {amount} {interval, select,\n month {per mėnesį}\n year {per metus}\n other {}\n}\n\nStripe netrukus atsiųs tau atskirą kvitą su sąskaitos faktūros PDF. Jame nurodyta visa mokėjimo informacija ir jį galima naudoti mokesčių tikslais.\n\nBet kuriuo metu šia nuoroda gali peržiūrėti savo paramos istoriją, atsisiųsti sąskaitas faktūras {interval, select,\n month {ir valdyti arba atšaukti savo prenumeratą}\n year {ir valdyti arba atšaukti savo prenumeratą}\n other {ir valdyti būsimą paramą}\n}:\n\n{manageUrl}\n\nTavo parama padeda {product_name} veikti. Ačiū!\n\n– {product_name} komanda"
},
"donation_magic_link": {
"subject": "Tvarkyk savo {product_name} paramą",
@@ -17,7 +17,7 @@ const EMAIL_I18N_NL_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Bedankt voor je {product_name}-donatie",
"body": "Hallo,\n\nBedankt voor je donatie aan {product_name}! Je {interval, select,\n month {terugkerende donatie}\n year {terugkerende donatie}\n other {eenmalige donatie}\n} is succesvol {interval, select,\n month {ingesteld}\n year {ingesteld}\n other {verwerkt}\n}.\n\nDonatiegegevens:\nBedrag: {amount} {currency} {interval, select,\n month {per maand}\n year {per jaar}\n other {}\n}\n\nStripe stuurt je binnenkort per e-mail een apart betalingsbewijs met je factuur als pdf. Dit bevat alle betalingsgegevens en kan worden gebruikt voor belastingdoeleinden.\n\nJe kunt op elk moment je donatiegeschiedenis bekijken, facturen downloaden en {interval, select,\n month {je abonnement beheren of opzeggen}\n year {je abonnement beheren of opzeggen}\n other {toekomstige donaties beheren}\n} via deze link:\n\n{manageUrl}\n\nJe steun helpt {product_name} draaiende te houden. Bedankt!\n\n– Het team van {product_name}"
"body": "Hallo,\n\nBedankt voor je donatie aan {product_name}! Je {interval, select,\n month {terugkerende donatie}\n year {terugkerende donatie}\n other {eenmalige donatie}\n} is succesvol {interval, select,\n month {ingesteld}\n year {ingesteld}\n other {verwerkt}\n}.\n\nDonatiegegevens:\nBedrag: {amount} {interval, select,\n month {per maand}\n year {per jaar}\n other {}\n}\n\nStripe stuurt je binnenkort per e-mail een apart betalingsbewijs met je factuur als pdf. Dit bevat alle betalingsgegevens en kan worden gebruikt voor belastingdoeleinden.\n\nJe kunt op elk moment je donatiegeschiedenis bekijken, facturen downloaden en {interval, select,\n month {je abonnement beheren of opzeggen}\n year {je abonnement beheren of opzeggen}\n other {toekomstige donaties beheren}\n} via deze link:\n\n{manageUrl}\n\nJe steun helpt {product_name} draaiende te houden. Bedankt!\n\n– Het team van {product_name}"
},
"donation_magic_link": {
"subject": "Beheer je {product_name}-donaties",
@@ -17,7 +17,7 @@ const EMAIL_I18N_NO_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Takk for bidraget ditt til {product_name}",
"body": "Hei,\n\nTakk for bidraget ditt til {product_name}! {interval, select,\n month {Det faste bidraget ditt er opprettet.}\n year {Det faste bidraget ditt er opprettet.}\n other {Engangsbidraget ditt er behandlet.}\n}\n\nDetaljer om bidraget:\nBeløp: {amount} {currency} {interval, select,\n month {per måned}\n year {per år}\n other {}\n}\n\nStripe sender deg snart en egen kvittering med fakturaen som PDF. Kvitteringen inneholder alle betalingsdetaljer og kan brukes til skatteformål.\n\nDu kan når som helst se bidragshistorikken din, laste ned fakturaer og {interval, select,\n month {administrere eller si opp abonnementet ditt}\n year {administrere eller si opp abonnementet ditt}\n other {administrere framtidige bidrag}\n} via denne lenken:\n\n{manageUrl}\n\nStøtten din bidrar til å holde {product_name} i gang. Takk!\n\n– {product_name}-teamet"
"body": "Hei,\n\nTakk for bidraget ditt til {product_name}! {interval, select,\n month {Det faste bidraget ditt er opprettet.}\n year {Det faste bidraget ditt er opprettet.}\n other {Engangsbidraget ditt er behandlet.}\n}\n\nDetaljer om bidraget:\nBeløp: {amount} {interval, select,\n month {per måned}\n year {per år}\n other {}\n}\n\nStripe sender deg snart en egen kvittering med fakturaen som PDF. Kvitteringen inneholder alle betalingsdetaljer og kan brukes til skatteformål.\n\nDu kan når som helst se bidragshistorikken din, laste ned fakturaer og {interval, select,\n month {administrere eller si opp abonnementet ditt}\n year {administrere eller si opp abonnementet ditt}\n other {administrere framtidige bidrag}\n} via denne lenken:\n\n{manageUrl}\n\nStøtten din bidrar til å holde {product_name} i gang. Takk!\n\n– {product_name}-teamet"
},
"donation_magic_link": {
"subject": "Administrer {product_name}-bidragene dine",
@@ -17,7 +17,7 @@ const EMAIL_I18N_PL_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Dziękujemy za darowiznę dla {product_name}",
"body": "Witaj,\n\nDziękujemy za darowiznę dla {product_name}! Twoja {interval, select,\n month {cykliczna darowizna}\n year {cykliczna darowizna}\n other {jednorazowa darowizna}\n} została {interval, select,\n month {uruchomiona}\n year {uruchomiona}\n other {przetworzona}\n} pomyślnie.\n\nSzczegóły darowizny:\nKwota: {amount} {currency} {interval, select,\n month {miesięcznie}\n year {rocznie}\n other {}\n}\n\nStripe wkrótce wyśle Ci osobną wiadomość e-mail z potwierdzeniem i fakturą w formacie PDF. Potwierdzenie zawiera wszystkie szczegóły płatności i może służyć do celów podatkowych.\n\nMożesz przeglądać historię swoich darowizn, pobierać faktury {interval, select,\n month {oraz zarządzać subskrypcją lub ją anulować}\n year {oraz zarządzać subskrypcją lub ją anulować}\n other {oraz zarządzać przyszłymi darowiznami}\n} w dowolnym momencie, korzystając z tego linku:\n\n{manageUrl}\n\nTwoje wsparcie pomaga utrzymać {product_name}. Dziękujemy!\n\n– Zespół {product_name}"
"body": "Witaj,\n\nDziękujemy za darowiznę dla {product_name}! Twoja {interval, select,\n month {cykliczna darowizna}\n year {cykliczna darowizna}\n other {jednorazowa darowizna}\n} została {interval, select,\n month {uruchomiona}\n year {uruchomiona}\n other {przetworzona}\n} pomyślnie.\n\nSzczegóły darowizny:\nKwota: {amount} {interval, select,\n month {miesięcznie}\n year {rocznie}\n other {}\n}\n\nStripe wkrótce wyśle Ci osobną wiadomość e-mail z potwierdzeniem i fakturą w formacie PDF. Potwierdzenie zawiera wszystkie szczegóły płatności i może służyć do celów podatkowych.\n\nMożesz przeglądać historię swoich darowizn, pobierać faktury {interval, select,\n month {oraz zarządzać subskrypcją lub ją anulować}\n year {oraz zarządzać subskrypcją lub ją anulować}\n other {oraz zarządzać przyszłymi darowiznami}\n} w dowolnym momencie, korzystając z tego linku:\n\n{manageUrl}\n\nTwoje wsparcie pomaga utrzymać {product_name}. Dziękujemy!\n\n– Zespół {product_name}"
},
"donation_magic_link": {
"subject": "Zarządzaj darowiznami dla {product_name}",
@@ -17,7 +17,7 @@ const EMAIL_I18N_PT_BR_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Agradecemos pela sua doação ao {product_name}",
"body": "Olá,\n\nAgradecemos pela sua doação ao {product_name}! Sua {interval, select,\n month {doação recorrente}\n year {doação recorrente}\n other {doação única}\n} foi {interval, select,\n month {configurada}\n year {configurada}\n other {processada}\n} com sucesso.\n\nDetalhes da doação:\nValor: {amount} {currency} {interval, select,\n month {por mês}\n year {por ano}\n other {}\n}\n\nA Stripe enviará um recibo separado com sua fatura em PDF em breve. Isso inclui todos os detalhes de pagamento e pode ser usado para fins fiscais.\n\nVocê pode visualizar seu histórico de doações, baixar faturas {interval, select,\n month {e gerenciar ou cancelar sua assinatura}\n year {e gerenciar ou cancelar sua assinatura}\n other {e gerenciar futuras doações}\n} a qualquer momento usando este link:\n\n{manageUrl}\n\nSeu apoio ajuda a manter o {product_name} funcionando. Agradecemos!\n\n– Equipe do {product_name}"
"body": "Olá,\n\nAgradecemos pela sua doação ao {product_name}! Sua {interval, select,\n month {doação recorrente}\n year {doação recorrente}\n other {doação única}\n} foi {interval, select,\n month {configurada}\n year {configurada}\n other {processada}\n} com sucesso.\n\nDetalhes da doação:\nValor: {amount} {interval, select,\n month {por mês}\n year {por ano}\n other {}\n}\n\nA Stripe enviará um recibo separado com sua fatura em PDF em breve. Isso inclui todos os detalhes de pagamento e pode ser usado para fins fiscais.\n\nVocê pode visualizar seu histórico de doações, baixar faturas {interval, select,\n month {e gerenciar ou cancelar sua assinatura}\n year {e gerenciar ou cancelar sua assinatura}\n other {e gerenciar futuras doações}\n} a qualquer momento usando este link:\n\n{manageUrl}\n\nSeu apoio ajuda a manter o {product_name} funcionando. Agradecemos!\n\n– Equipe do {product_name}"
},
"donation_magic_link": {
"subject": "Gerencie suas doações ao {product_name}",
@@ -17,7 +17,7 @@ const EMAIL_I18N_RO_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Îți mulțumim pentru donația către {product_name}",
"body": "Salut,\n\nÎți mulțumim pentru donația ta către {product_name}! {interval, select,\n month {Donația ta recurentă}\n year {Donația ta recurentă}\n other {Donația ta unică}\n} a fost {interval, select,\n month {configurată}\n year {configurată}\n other {procesată}\n} cu succes.\n\nDetalii donație:\nSumă: {amount} {currency} {interval, select,\n month {pe lună}\n year {pe an}\n other {}\n}\n\nStripe îți va trimite în scurt timp un e-mail separat cu chitanța și factura ta în format PDF. Acestea includ toate detaliile plății și pot fi folosite în scopuri fiscale.\n\nPoți vizualiza istoricul donațiilor, descărca facturi {interval, select,\n month {și gestiona sau anula abonamentul}\n year {și gestiona sau anula abonamentul}\n other {și gestiona donațiile viitoare}\n} în orice moment folosind acest link:\n\n{manageUrl}\n\nSprijinul tău ne ajută să menținem {product_name} în funcțiune. Mulțumim!\n\n– Echipa {product_name}"
"body": "Salut,\n\nÎți mulțumim pentru donația ta către {product_name}! {interval, select,\n month {Donația ta recurentă}\n year {Donația ta recurentă}\n other {Donația ta unică}\n} a fost {interval, select,\n month {configurată}\n year {configurată}\n other {procesată}\n} cu succes.\n\nDetalii donație:\nSumă: {amount} {interval, select,\n month {pe lună}\n year {pe an}\n other {}\n}\n\nStripe îți va trimite în scurt timp un e-mail separat cu chitanța și factura ta în format PDF. Acestea includ toate detaliile plății și pot fi folosite în scopuri fiscale.\n\nPoți vizualiza istoricul donațiilor, descărca facturi {interval, select,\n month {și gestiona sau anula abonamentul}\n year {și gestiona sau anula abonamentul}\n other {și gestiona donațiile viitoare}\n} în orice moment folosind acest link:\n\n{manageUrl}\n\nSprijinul tău ne ajută să menținem {product_name} în funcțiune. Mulțumim!\n\n– Echipa {product_name}"
},
"donation_magic_link": {
"subject": "Gestionează-ți donațiile către {product_name}",
@@ -17,7 +17,7 @@ const EMAIL_I18N_RU_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Спасибо за твоё пожертвование для {product_name}",
"body": "Привет,\n\nСпасибо за твоё пожертвование для {product_name}! Твоё {interval, select,\n month {регулярное пожертвование}\n year {регулярное пожертвование}\n other {разовое пожертвование}\n} успешно {interval, select,\n month {настроено}\n year {настроено}\n other {обработано}\n}.\n\nДетали пожертвования:\nСумма: {amount} {currency} {interval, select,\n month {в месяц}\n year {в год}\n other {}\n}\n\nStripe вскоре отправит тебе отдельную квитанцию со счётом в формате PDF. В ней есть все платёжные данные, и её можно использовать для налоговых целей.\n\nТы можешь просматривать историю своих пожертвований, скачивать счета, {interval, select,\n month {а также управлять подпиской или отменить её}\n year {а также управлять подпиской или отменить её}\n other {и управлять будущими пожертвованиями}\n} в любое время по этой ссылке:\n\n{manageUrl}\n\nТвоя поддержка помогает {product_name} работать. Спасибо!\n\n– Команда {product_name}"
"body": "Привет,\n\nСпасибо за твоё пожертвование для {product_name}! Твоё {interval, select,\n month {регулярное пожертвование}\n year {регулярное пожертвование}\n other {разовое пожертвование}\n} успешно {interval, select,\n month {настроено}\n year {настроено}\n other {обработано}\n}.\n\nДетали пожертвования:\nСумма: {amount} {interval, select,\n month {в месяц}\n year {в год}\n other {}\n}\n\nStripe вскоре отправит тебе отдельную квитанцию со счётом в формате PDF. В ней есть все платёжные данные, и её можно использовать для налоговых целей.\n\nТы можешь просматривать историю своих пожертвований, скачивать счета, {interval, select,\n month {а также управлять подпиской или отменить её}\n year {а также управлять подпиской или отменить её}\n other {и управлять будущими пожертвованиями}\n} в любое время по этой ссылке:\n\n{manageUrl}\n\nТвоя поддержка помогает {product_name} работать. Спасибо!\n\n– Команда {product_name}"
},
"donation_magic_link": {
"subject": "Управляй своими пожертвованиями для {product_name}",
@@ -17,7 +17,7 @@ const EMAIL_I18N_SV_SE_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Tack för din donation till {product_name}",
"body": "Hej,\n\nTack för din donation till {product_name}! {interval, select,\n month {Din återkommande donation har registrerats.}\n year {Din återkommande donation har registrerats.}\n other {Din engångsdonation har genomförts.}\n}\n\nDonationsuppgifter:\nBelopp: {amount} {currency} {interval, select,\n month {per månad}\n year {per år}\n other {}\n}\n\nStripe skickar snart ett separat kvitto med din faktura i PDF-format via e-post. Där finns alla betalningsuppgifter, och underlaget kan användas för skatteändamål.\n\nDu kan se din donationshistorik, ladda ner fakturor {interval, select,\n month {och hantera eller avsluta din återkommande donation}\n year {och hantera eller avsluta din återkommande donation}\n other {och hantera framtida donationer}\n} när som helst via den här länken:\n\n{manageUrl}\n\nDitt stöd hjälper till att hålla {product_name} igång. Tack!\n\n– {product_name}-teamet"
"body": "Hej,\n\nTack för din donation till {product_name}! {interval, select,\n month {Din återkommande donation har registrerats.}\n year {Din återkommande donation har registrerats.}\n other {Din engångsdonation har genomförts.}\n}\n\nDonationsuppgifter:\nBelopp: {amount} {interval, select,\n month {per månad}\n year {per år}\n other {}\n}\n\nStripe skickar snart ett separat kvitto med din faktura i PDF-format via e-post. Där finns alla betalningsuppgifter, och underlaget kan användas för skatteändamål.\n\nDu kan se din donationshistorik, ladda ner fakturor {interval, select,\n month {och hantera eller avsluta din återkommande donation}\n year {och hantera eller avsluta din återkommande donation}\n other {och hantera framtida donationer}\n} när som helst via den här länken:\n\n{manageUrl}\n\nDitt stöd hjälper till att hålla {product_name} igång. Tack!\n\n– {product_name}-teamet"
},
"donation_magic_link": {
"subject": "Hantera dina donationer till {product_name}",
@@ -17,7 +17,7 @@ const EMAIL_I18N_TH_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "ขอบคุณสำหรับการบริจาคให้ {product_name}",
"body": "สวัสดี,\n\nขอบคุณสำหรับการบริจาคให้ {product_name}! {interval, select,\n month {การบริจาคแบบต่อเนื่อง}\n year {การบริจาคแบบต่อเนื่อง}\n other {การบริจาคครั้งเดียว}\n}ของคุณได้รับการ{interval, select,\n month {ตั้งค่า}\n year {ตั้งค่า}\n other {ดำเนินการ}\n}เรียบร้อยแล้ว\n\nรายละเอียดการบริจาค:\nจำนวนเงิน: {amount} {currency} {interval, select,\n month {ต่อเดือน}\n year {ต่อปี}\n other {}\n}\n\nStripe จะส่งอีเมลใบเสร็จแยกต่างหากพร้อมไฟล์ PDF ใบแจ้งหนี้ให้คุณในไม่ช้า ใบเสร็จนี้มีรายละเอียดการชำระเงินทั้งหมดและใช้สำหรับวัตถุประสงค์ทางภาษีได้\n\nคุณสามารถดูประวัติการบริจาค ดาวน์โหลดใบแจ้งหนี้ {interval, select,\n month {และจัดการหรือยกเลิกการสมัครสมาชิกของคุณ}\n year {และจัดการหรือยกเลิกการสมัครสมาชิกของคุณ}\n other {และจัดการการบริจาคในอนาคต}\n} ได้ตลอดเวลาโดยใช้ลิงก์นี้:\n\n{manageUrl}\n\nการสนับสนุนของคุณช่วยให้ {product_name} ดำเนินต่อไปได้ ขอบคุณ!\n\n– ทีม {product_name}"
"body": "สวัสดี,\n\nขอบคุณสำหรับการบริจาคให้ {product_name}! {interval, select,\n month {การบริจาคแบบต่อเนื่อง}\n year {การบริจาคแบบต่อเนื่อง}\n other {การบริจาคครั้งเดียว}\n}ของคุณได้รับการ{interval, select,\n month {ตั้งค่า}\n year {ตั้งค่า}\n other {ดำเนินการ}\n}เรียบร้อยแล้ว\n\nรายละเอียดการบริจาค:\nจำนวนเงิน: {amount} {interval, select,\n month {ต่อเดือน}\n year {ต่อปี}\n other {}\n}\n\nStripe จะส่งอีเมลใบเสร็จแยกต่างหากพร้อมไฟล์ PDF ใบแจ้งหนี้ให้คุณในไม่ช้า ใบเสร็จนี้มีรายละเอียดการชำระเงินทั้งหมดและใช้สำหรับวัตถุประสงค์ทางภาษีได้\n\nคุณสามารถดูประวัติการบริจาค ดาวน์โหลดใบแจ้งหนี้ {interval, select,\n month {และจัดการหรือยกเลิกการสมัครสมาชิกของคุณ}\n year {และจัดการหรือยกเลิกการสมัครสมาชิกของคุณ}\n other {และจัดการการบริจาคในอนาคต}\n} ได้ตลอดเวลาโดยใช้ลิงก์นี้:\n\n{manageUrl}\n\nการสนับสนุนของคุณช่วยให้ {product_name} ดำเนินต่อไปได้ ขอบคุณ!\n\n– ทีม {product_name}"
},
"donation_magic_link": {
"subject": "จัดการการบริจาคให้ {product_name}",
@@ -17,7 +17,7 @@ const EMAIL_I18N_TR_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "{product_name}'a yaptığın bağış için teşekkürler",
"body": "Merhaba,\n\n{product_name}'a yaptığın bağış için teşekkürler! {interval, select,\n month {tekrarlayan bağışın}\n year {tekrarlayan bağışın}\n other {tek seferlik bağışın}\n} başarıyla {interval, select,\n month {ayarlandı}\n year {ayarlandı}\n other {işlendi}\n}.\n\nBağış detayları:\nTutar: {amount} {currency} {interval, select,\n month {aylık}\n year {yıllık}\n other {}\n}\n\nStripe, kısa süre içinde fatura PDF'ini içeren ayrı bir makbuzu sana e-posta ile gönderecektir. Bu, tüm ödeme detaylarını içerir ve vergi amaçlı kullanılabilir.\n\nBu bağlantıyı kullanarak istediğin zaman bağış geçmişini görüntüleyebilir, faturaları indirebilir, {interval, select,\n month {ve aboneliğini yönetebilir veya iptal edebilirsin}\n year {ve aboneliğini yönetebilir veya iptal edebilirsin}\n other {ve gelecekteki bağışlarını yönetebilirsin}\n}:\n\n{manageUrl}\n\nDesteğin {product_name}'ın çalışmaya devam etmesine yardımcı oluyor. Teşekkürler!\n\n– {product_name} Ekibi"
"body": "Merhaba,\n\n{product_name}'a yaptığın bağış için teşekkürler! {interval, select,\n month {tekrarlayan bağışın}\n year {tekrarlayan bağışın}\n other {tek seferlik bağışın}\n} başarıyla {interval, select,\n month {ayarlandı}\n year {ayarlandı}\n other {işlendi}\n}.\n\nBağış detayları:\nTutar: {amount} {interval, select,\n month {aylık}\n year {yıllık}\n other {}\n}\n\nStripe, kısa süre içinde fatura PDF'ini içeren ayrı bir makbuzu sana e-posta ile gönderecektir. Bu, tüm ödeme detaylarını içerir ve vergi amaçlı kullanılabilir.\n\nBu bağlantıyı kullanarak istediğin zaman bağış geçmişini görüntüleyebilir, faturaları indirebilir, {interval, select,\n month {ve aboneliğini yönetebilir veya iptal edebilirsin}\n year {ve aboneliğini yönetebilir veya iptal edebilirsin}\n other {ve gelecekteki bağışlarını yönetebilirsin}\n}:\n\n{manageUrl}\n\nDesteğin {product_name}'ın çalışmaya devam etmesine yardımcı oluyor. Teşekkürler!\n\n– {product_name} Ekibi"
},
"donation_magic_link": {
"subject": "{product_name} bağışlarını yönet",
@@ -17,7 +17,7 @@ const EMAIL_I18N_UK_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Дякуємо за твою пожертву для {product_name}",
"body": "Привіт,\n\nДякуємо за твою пожертву для {product_name}! Твою {interval, select,\n month {регулярну пожертву}\n year {регулярну пожертву}\n other {одноразову пожертву}\n} успішно {interval, select,\n month {налаштовано}\n year {налаштовано}\n other {оброблено}\n}.\n\nДеталі пожертви:\nСума: {amount} {currency} {interval, select,\n month {на місяць}\n year {на рік}\n other {}\n}\n\nStripe незабаром надішле тобі окрему квитанцію з рахунком у форматі PDF. Вона містить усі платіжні дані, і її можна використати для податкових цілей.\n\nТи можеш переглядати історію своїх пожертв, завантажувати рахунки, {interval, select,\n month {а також керувати підпискою або скасувати її}\n year {а також керувати підпискою або скасувати її}\n other {а також керувати майбутніми пожертвами}\n} у будь-який час за цим посиланням:\n\n{manageUrl}\n\nТвоя підтримка допомагає {product_name} працювати. Дякуємо!\n\n– Команда {product_name}"
"body": "Привіт,\n\nДякуємо за твою пожертву для {product_name}! Твою {interval, select,\n month {регулярну пожертву}\n year {регулярну пожертву}\n other {одноразову пожертву}\n} успішно {interval, select,\n month {налаштовано}\n year {налаштовано}\n other {оброблено}\n}.\n\nДеталі пожертви:\nСума: {amount} {interval, select,\n month {на місяць}\n year {на рік}\n other {}\n}\n\nStripe незабаром надішле тобі окрему квитанцію з рахунком у форматі PDF. Вона містить усі платіжні дані, і її можна використати для податкових цілей.\n\nТи можеш переглядати історію своїх пожертв, завантажувати рахунки, {interval, select,\n month {а також керувати підпискою або скасувати її}\n year {а також керувати підпискою або скасувати її}\n other {а також керувати майбутніми пожертвами}\n} у будь-який час за цим посиланням:\n\n{manageUrl}\n\nТвоя підтримка допомагає {product_name} працювати. Дякуємо!\n\n– Команда {product_name}"
},
"donation_magic_link": {
"subject": "Керуй своїми пожертвами для {product_name}",
@@ -17,7 +17,7 @@ const EMAIL_I18N_VI_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Cảm ơn bạn đã ủng hộ {product_name}",
"body": "Xin chào,\n\nCảm ơn bạn đã ủng hộ {product_name}! {interval, select,\n month {Khoản ủng hộ định kỳ}\n year {Khoản ủng hộ định kỳ}\n other {Khoản ủng hộ một lần}\n} của bạn đã được {interval, select,\n month {thiết lập}\n year {thiết lập}\n other {xử lý}\n} thành công.\n\nChi tiết ủng hộ:\nSố tiền: {amount} {currency} {interval, select,\n month {mỗi tháng}\n year {mỗi năm}\n other {}\n}\n\nStripe sẽ sớm gửi cho bạn một email biên lai riêng kèm hóa đơn PDF. Biên lai này gồm đầy đủ chi tiết thanh toán và có thể dùng cho mục đích thuế.\n\nBạn có thể xem lịch sử ủng hộ, tải xuống hóa đơn, {interval, select,\n month {và quản lý hoặc hủy gói đăng ký}\n year {và quản lý hoặc hủy gói đăng ký}\n other {và quản lý các khoản ủng hộ trong tương lai}\n} bất cứ lúc nào bằng cách sử dụng liên kết này:\n\n{manageUrl}\n\nSự hỗ trợ của bạn giúp {product_name} tiếp tục hoạt động. Cảm ơn bạn!\n\n– Đội ngũ {product_name}"
"body": "Xin chào,\n\nCảm ơn bạn đã ủng hộ {product_name}! {interval, select,\n month {Khoản ủng hộ định kỳ}\n year {Khoản ủng hộ định kỳ}\n other {Khoản ủng hộ một lần}\n} của bạn đã được {interval, select,\n month {thiết lập}\n year {thiết lập}\n other {xử lý}\n} thành công.\n\nChi tiết ủng hộ:\nSố tiền: {amount} {interval, select,\n month {mỗi tháng}\n year {mỗi năm}\n other {}\n}\n\nStripe sẽ sớm gửi cho bạn một email biên lai riêng kèm hóa đơn PDF. Biên lai này gồm đầy đủ chi tiết thanh toán và có thể dùng cho mục đích thuế.\n\nBạn có thể xem lịch sử ủng hộ, tải xuống hóa đơn, {interval, select,\n month {và quản lý hoặc hủy gói đăng ký}\n year {và quản lý hoặc hủy gói đăng ký}\n other {và quản lý các khoản ủng hộ trong tương lai}\n} bất cứ lúc nào bằng cách sử dụng liên kết này:\n\n{manageUrl}\n\nSự hỗ trợ của bạn giúp {product_name} tiếp tục hoạt động. Cảm ơn bạn!\n\n– Đội ngũ {product_name}"
},
"donation_magic_link": {
"subject": "Quản lý các khoản ủng hộ {product_name} của bạn",
@@ -17,7 +17,7 @@ const EMAIL_I18N_ZH_CN_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "感谢你的 {product_name} 捐赠",
"body": "你好,\n\n感谢你对 {product_name} 的捐赠!你的{interval, select,\n month {定期捐赠}\n year {定期捐赠}\n other {一次性捐赠}\n}{interval, select,\n month {已成功设置}\n year {已成功设置}\n other {已成功处理}\n}。\n\n捐赠详情:\n金额:{amount} {currency} {interval, select,\n month {每月}\n year {每年}\n other {}\n}\n\nStripe 稍后会另外发送一封收据邮件,其中附有发票 PDF。邮件包含所有付款详情,可用于报税。\n\n你可以随时使用此链接查看你的捐赠历史、下载发票,以及{interval, select,\n month {管理或取消你的订阅}\n year {管理或取消你的订阅}\n other {管理你的未来捐赠}\n}:\n\n{manageUrl}\n\n你的支持有助于 {product_name} 的持续运行。谢谢!\n\n– {product_name} 团队"
"body": "你好,\n\n感谢你对 {product_name} 的捐赠!你的{interval, select,\n month {定期捐赠}\n year {定期捐赠}\n other {一次性捐赠}\n}{interval, select,\n month {已成功设置}\n year {已成功设置}\n other {已成功处理}\n}。\n\n捐赠详情:\n金额:{amount} {interval, select,\n month {每月}\n year {每年}\n other {}\n}\n\nStripe 稍后会另外发送一封收据邮件,其中附有发票 PDF。邮件包含所有付款详情,可用于报税。\n\n你可以随时使用此链接查看你的捐赠历史、下载发票,以及{interval, select,\n month {管理或取消你的订阅}\n year {管理或取消你的订阅}\n other {管理你的未来捐赠}\n}:\n\n{manageUrl}\n\n你的支持有助于 {product_name} 的持续运行。谢谢!\n\n– {product_name} 团队"
},
"donation_magic_link": {
"subject": "管理你的 {product_name} 捐赠",
@@ -17,7 +17,7 @@ const EMAIL_I18N_ZH_TW_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "感謝您對 {product_name} 的捐款",
"body": "哈囉,\n\n感謝您對 {product_name} 的捐款!您的{interval, select,\n month {定期捐款}\n year {定期捐款}\n other {一次性捐款}\n}已成功{interval, select,\n month {設定}\n year {設定}\n other {處理}\n}。\n\n捐款詳情:\n金額:{amount} {currency} {interval, select,\n month {每月}\n year {每年}\n other {}\n}\n\nStripe 稍後會另外寄給您一封收據,其中附有發票 PDF。這份收據包含所有付款詳情,可用於報稅。\n\n您可以隨時使用此連結查看您的捐款紀錄、下載發票,{interval, select,\n month {以及管理或取消您的訂閱}\n year {以及管理或取消您的訂閱}\n other {以及管理未來的捐款}\n}:\n\n{manageUrl}\n\n您的支持讓 {product_name} 得以持續運作。謝謝您!\n\n– {product_name} 團隊"
"body": "哈囉,\n\n感謝您對 {product_name} 的捐款!您的{interval, select,\n month {定期捐款}\n year {定期捐款}\n other {一次性捐款}\n}已成功{interval, select,\n month {設定}\n year {設定}\n other {處理}\n}。\n\n捐款詳情:\n金額:{amount} {interval, select,\n month {每月}\n year {每年}\n other {}\n}\n\nStripe 稍後會另外寄給您一封收據,其中附有發票 PDF。這份收據包含所有付款詳情,可用於報稅。\n\n您可以隨時使用此連結查看您的捐款紀錄、下載發票,{interval, select,\n month {以及管理或取消您的訂閱}\n year {以及管理或取消您的訂閱}\n other {以及管理未來的捐款}\n}:\n\n{manageUrl}\n\n您的支持讓 {product_name} 得以持續運作。謝謝您!\n\n– {product_name} 團隊"
},
"donation_magic_link": {
"subject": "管理您的 {product_name} 捐款",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "شكرًا لك على تبرعك لـ{product_name}",
"body": "مرحبًا،\n\nشكرًا لك على تبرعك لـ{product_name}! لقد تم {interval, select,\n month {إعداد تبرعك المتكرر}\n year {إعداد تبرعك المتكرر}\n other {معالجة تبرعك لمرة واحدة}\n} بنجاح.\n\nتفاصيل التبرع:\nالمبلغ: {amount} {currency} {interval, select,\n month {شهريًا}\n year {سنويًا}\n other {}\n}\n\nسترسل لك Stripe إيصالًا منفصلًا عبر البريد الإلكتروني مع فاتورتك بصيغة PDF قريبًا. يتضمن هذا جميع تفاصيل الدفع ويمكن استخدامه لأغراض ضريبية.\n\nيمكنك عرض سجل تبرعاتك، وتنزيل الفواتير، {interval, select,\n month {وإدارة أو إلغاء اشتراكك}\n year {وإدارة أو إلغاء اشتراكك}\n other {وإدارة التبرعات المستقبلية}\n} في أي وقت باستخدام هذا الرابط:\n\n{manageUrl}\n\nدعمك يساعد على استمرار {product_name}. شكرًا لك!\n\n– فريق {product_name}"
"body": "مرحبًا،\n\nشكرًا لك على تبرعك لـ{product_name}! لقد تم {interval, select,\n month {إعداد تبرعك المتكرر}\n year {إعداد تبرعك المتكرر}\n other {معالجة تبرعك لمرة واحدة}\n} بنجاح.\n\nتفاصيل التبرع:\nالمبلغ: {amount} {interval, select,\n month {شهريًا}\n year {سنويًا}\n other {}\n}\n\nسترسل لك Stripe إيصالًا منفصلًا عبر البريد الإلكتروني مع فاتورتك بصيغة PDF قريبًا. يتضمن هذا جميع تفاصيل الدفع ويمكن استخدامه لأغراض ضريبية.\n\nيمكنك عرض سجل تبرعاتك، وتنزيل الفواتير، {interval, select,\n month {وإدارة أو إلغاء اشتراكك}\n year {وإدارة أو إلغاء اشتراكك}\n other {وإدارة التبرعات المستقبلية}\n} في أي وقت باستخدام هذا الرابط:\n\n{manageUrl}\n\nدعمك يساعد على استمرار {product_name}. شكرًا لك!\n\n– فريق {product_name}"
},
"donation_magic_link": {
"subject": "إدارة تبرعاتك لـ{product_name}",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Благодарим ти за дарението към {product_name}",
"body": "Здравей,\n\nБлагодарим ти за дарението към {product_name}! Твоето {interval, select,\n month {периодично дарение}\n year {периодично дарение}\n other {еднократно дарение}\n} беше {interval, select,\n month {настроено}\n year {настроено}\n other {обработено}\n} успешно.\n\nПодробности за дарението:\nСума: {amount} {currency} {interval, select,\n month {на месец}\n year {на година}\n other {}\n}\n\nСкоро Stripe ще ти изпрати по имейл отделна разписка с PDF на фактурата ти. Тя включва всички данни за плащане и може да се използва за данъчни цели.\n\nМожеш да преглеждаш историята на даренията си, да изтегляш фактури {interval, select,\n month {и да управляваш или анулираш абонамента си}\n year {и да управляваш или анулираш абонамента си}\n other {и да управляваш бъдещи дарения}\n} по всяко време, като използваш този линк:\n\n{manageUrl}\n\nТвоята подкрепа помага на {product_name} да продължи да работи. Благодарим ти!\n\n– Екип на {product_name}"
"body": "Здравей,\n\nБлагодарим ти за дарението към {product_name}! Твоето {interval, select,\n month {периодично дарение}\n year {периодично дарение}\n other {еднократно дарение}\n} беше {interval, select,\n month {настроено}\n year {настроено}\n other {обработено}\n} успешно.\n\nПодробности за дарението:\nСума: {amount} {interval, select,\n month {на месец}\n year {на година}\n other {}\n}\n\nСкоро Stripe ще ти изпрати по имейл отделна разписка с PDF на фактурата ти. Тя включва всички данни за плащане и може да се използва за данъчни цели.\n\nМожеш да преглеждаш историята на даренията си, да изтегляш фактури {interval, select,\n month {и да управляваш или анулираш абонамента си}\n year {и да управляваш или анулираш абонамента си}\n other {и да управляваш бъдещи дарения}\n} по всяко време, като използваш този линк:\n\n{manageUrl}\n\nТвоята подкрепа помага на {product_name} да продължи да работи. Благодарим ти!\n\n– Екип на {product_name}"
},
"donation_magic_link": {
"subject": "Управлявай даренията си за {product_name}",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Děkujeme za váš dar pro {product_name}",
"body": "Dobrý den,\n\nDěkujeme za váš dar pro {product_name}! Váš {interval, select,\n month {opakovaný dar}\n year {opakovaný dar}\n other {jednorázový dar}\n} byl úspěšně {interval, select,\n month {nastaven}\n year {nastaven}\n other {zpracován}\n}.\n\nPodrobnosti o daru:\nČástka: {amount} {currency} {interval, select,\n month {měsíčně}\n year {ročně}\n other {}\n}\n\nStripe vám brzy pošle samostatné potvrzení platby s fakturou v PDF. Obsahuje všechny platební údaje a lze ho použít pro daňové účely.\n\nPomocí tohoto odkazu si můžete kdykoli prohlédnout historii svých darů, stáhnout faktury {interval, select,\n month {a spravovat nebo zrušit předplatné}\n year {a spravovat nebo zrušit předplatné}\n other {a spravovat budoucí dary}\n}:\n\n{manageUrl}\n\nVaše podpora pomáhá udržovat {product_name} v chodu. Děkujeme!\n\n– Tým {product_name}"
"body": "Dobrý den,\n\nDěkujeme za váš dar pro {product_name}! Váš {interval, select,\n month {opakovaný dar}\n year {opakovaný dar}\n other {jednorázový dar}\n} byl úspěšně {interval, select,\n month {nastaven}\n year {nastaven}\n other {zpracován}\n}.\n\nPodrobnosti o daru:\nČástka: {amount} {interval, select,\n month {měsíčně}\n year {ročně}\n other {}\n}\n\nStripe vám brzy pošle samostatné potvrzení platby s fakturou v PDF. Obsahuje všechny platební údaje a lze ho použít pro daňové účely.\n\nPomocí tohoto odkazu si můžete kdykoli prohlédnout historii svých darů, stáhnout faktury {interval, select,\n month {a spravovat nebo zrušit předplatné}\n year {a spravovat nebo zrušit předplatné}\n other {a spravovat budoucí dary}\n}:\n\n{manageUrl}\n\nVaše podpora pomáhá udržovat {product_name} v chodu. Děkujeme!\n\n– Tým {product_name}"
},
"donation_magic_link": {
"subject": "Spravujte své dary pro {product_name}",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Tak for din {product_name}-donation",
"body": "Hej,\n\nTak for din donation til {product_name}! Din {interval, select,\n month {løbende donation}\n year {løbende donation}\n other {engangsdonation}\n} er blevet {interval, select,\n month {oprettet}\n year {oprettet}\n other {gennemført}\n}.\n\nDonationsdetaljer:\nBeløb: {amount} {currency} {interval, select,\n month {pr. måned}\n year {pr. år}\n other {}\n}\n\nStripe sender dig en separat kvittering med din faktura som PDF inden længe. Kvitteringen indeholder alle betalingsdetaljer og kan bruges til skatteformål.\n\nDu kan til enhver tid se din donationshistorik, downloade fakturaer {interval, select,\n month {og administrere eller opsige dit abonnement}\n year {og administrere eller opsige dit abonnement}\n other {og administrere fremtidige donationer}\n} ved at bruge dette link:\n\n{manageUrl}\n\nDin støtte hjælper med at holde {product_name} kørende. Tak!\n\n– Teamet bag {product_name}"
"body": "Hej,\n\nTak for din donation til {product_name}! Din {interval, select,\n month {løbende donation}\n year {løbende donation}\n other {engangsdonation}\n} er blevet {interval, select,\n month {oprettet}\n year {oprettet}\n other {gennemført}\n}.\n\nDonationsdetaljer:\nBeløb: {amount} {interval, select,\n month {pr. måned}\n year {pr. år}\n other {}\n}\n\nStripe sender dig en separat kvittering med din faktura som PDF inden længe. Kvitteringen indeholder alle betalingsdetaljer og kan bruges til skatteformål.\n\nDu kan til enhver tid se din donationshistorik, downloade fakturaer {interval, select,\n month {og administrere eller opsige dit abonnement}\n year {og administrere eller opsige dit abonnement}\n other {og administrere fremtidige donationer}\n} ved at bruge dette link:\n\n{manageUrl}\n\nDin støtte hjælper med at holde {product_name} kørende. Tak!\n\n– Teamet bag {product_name}"
},
"donation_magic_link": {
"subject": "Administrer dine {product_name}-donationer",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Vielen Dank für deine {product_name}-Spende",
"body": "Hallo,\n\nvielen Dank für deine Spende an {product_name}! Deine {interval, select,\n month {wiederkehrende Spende}\n year {wiederkehrende Spende}\n other {einmalige Spende}\n} wurde erfolgreich {interval, select,\n month {eingerichtet}\n year {eingerichtet}\n other {verarbeitet}\n}.\n\nDetails zur Spende:\nBetrag: {amount} {currency} {interval, select,\n month {pro Monat}\n year {pro Jahr}\n other {}\n}\n\nStripe sendet dir in Kürze eine separate Quittung mit deiner Rechnungs-PDF per E-Mail. Diese enthält alle Zahlungsdetails und kann für Steuerzwecke verwendet werden.\n\nÜber diesen Link kannst du jederzeit deine Spendenübersicht aufrufen, Rechnungen herunterladen {interval, select,\n month {und dein Abonnement verwalten oder kündigen}\n year {und dein Abonnement verwalten oder kündigen}\n other {und zukünftige Spenden verwalten}\n}:\n\n{manageUrl}\n\nDeine Unterstützung hilft, {product_name} am Laufen zu halten. Vielen Dank!\n\n– {product_name}-Team"
"body": "Hallo,\n\nvielen Dank für deine Spende an {product_name}! Deine {interval, select,\n month {wiederkehrende Spende}\n year {wiederkehrende Spende}\n other {einmalige Spende}\n} wurde erfolgreich {interval, select,\n month {eingerichtet}\n year {eingerichtet}\n other {verarbeitet}\n}.\n\nDetails zur Spende:\nBetrag: {amount} {interval, select,\n month {pro Monat}\n year {pro Jahr}\n other {}\n}\n\nStripe sendet dir in Kürze eine separate Quittung mit deiner Rechnungs-PDF per E-Mail. Diese enthält alle Zahlungsdetails und kann für Steuerzwecke verwendet werden.\n\nÜber diesen Link kannst du jederzeit deine Spendenübersicht aufrufen, Rechnungen herunterladen {interval, select,\n month {und dein Abonnement verwalten oder kündigen}\n year {und dein Abonnement verwalten oder kündigen}\n other {und zukünftige Spenden verwalten}\n}:\n\n{manageUrl}\n\nDeine Unterstützung hilft, {product_name} am Laufen zu halten. Vielen Dank!\n\n– {product_name}-Team"
},
"donation_magic_link": {
"subject": "Verwalte deine {product_name}-Spenden",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Ευχαριστούμε για τη δωρεά σου στο {product_name}",
"body": "Γεια σου,\n\nΕυχαριστούμε για τη δωρεά σου στο {product_name}! Η {interval, select,\n month {επαναλαμβανόμενη δωρεά}\n year {επαναλαμβανόμενη δωρεά}\n other {εφάπαξ δωρεά}\n} σου έχει {interval, select,\n month {ρυθμιστεί}\n year {ρυθμιστεί}\n other {ολοκληρωθεί}\n} με επιτυχία.\n\nΛεπτομέρειες δωρεάς:\nΠοσό: {amount} {currency} {interval, select,\n month {ανά μήνα}\n year {ανά έτος}\n other {}\n}\n\nΗ Stripe θα σου στείλει σύντομα ξεχωριστή απόδειξη με το PDF του τιμολογίου σου. Περιλαμβάνει όλες τις λεπτομέρειες πληρωμής και μπορεί να χρησιμοποιηθεί για φορολογικούς σκοπούς.\n\nΜπορείς να δεις το ιστορικό των δωρεών σου, να κατεβάσεις τιμολόγια, {interval, select,\n month {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n year {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n other {και να διαχειριστείς μελλοντικές δωρεές}\n} ανά πάσα στιγμή χρησιμοποιώντας αυτόν τον σύνδεσμο:\n\n{manageUrl}\n\nΗ υποστήριξή σου βοηθάει το {product_name} να συνεχίσει να λειτουργεί. Ευχαριστούμε!\n\n– Η ομάδα του {product_name}"
"body": "Γεια σου,\n\nΕυχαριστούμε για τη δωρεά σου στο {product_name}! Η {interval, select,\n month {επαναλαμβανόμενη δωρεά}\n year {επαναλαμβανόμενη δωρεά}\n other {εφάπαξ δωρεά}\n} σου έχει {interval, select,\n month {ρυθμιστεί}\n year {ρυθμιστεί}\n other {ολοκληρωθεί}\n} με επιτυχία.\n\nΛεπτομέρειες δωρεάς:\nΠοσό: {amount} {interval, select,\n month {ανά μήνα}\n year {ανά έτος}\n other {}\n}\n\nΗ Stripe θα σου στείλει σύντομα ξεχωριστή απόδειξη με το PDF του τιμολογίου σου. Περιλαμβάνει όλες τις λεπτομέρειες πληρωμής και μπορεί να χρησιμοποιηθεί για φορολογικούς σκοπούς.\n\nΜπορείς να δεις το ιστορικό των δωρεών σου, να κατεβάσεις τιμολόγια, {interval, select,\n month {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n year {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n other {και να διαχειριστείς μελλοντικές δωρεές}\n} ανά πάσα στιγμή χρησιμοποιώντας αυτόν τον σύνδεσμο:\n\n{manageUrl}\n\nΗ υποστήριξή σου βοηθάει το {product_name} να συνεχίσει να λειτουργεί. Ευχαριστούμε!\n\n– Η ομάδα του {product_name}"
},
"donation_magic_link": {
"subject": "Διαχειρίσου τις δωρεές σου στο {product_name}",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Thank you for your {product_name} donation",
"body": "Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {currency} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team"
"body": "Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team"
},
"donation_magic_link": {
"subject": "Manage your {product_name} donations",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Gracias por tu donación a {product_name}",
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} ha sido {interval, select,\n month {configurada}\n year {configurada}\n other {procesada}\n} con éxito.\n\nDetalles de la donación:\nMonto: {amount} {currency} {interval, select,\n month {por mes}\n year {por año}\n other {}\n}\n\nEn breve, Stripe te enviará por separado un correo electrónico con el recibo y la factura en PDF. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y administrar o cancelar tu suscripción}\n year {y administrar o cancelar tu suscripción}\n other {y administrar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} ha sido {interval, select,\n month {configurada}\n year {configurada}\n other {procesada}\n} con éxito.\n\nDetalles de la donación:\nMonto: {amount} {interval, select,\n month {por mes}\n year {por año}\n other {}\n}\n\nEn breve, Stripe te enviará por separado un correo electrónico con el recibo y la factura en PDF. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y administrar o cancelar tu suscripción}\n year {y administrar o cancelar tu suscripción}\n other {y administrar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
},
"donation_magic_link": {
"subject": "Administra tus donaciones a {product_name}",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Gracias por tu donación a {product_name}",
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} se ha {interval, select,\n month {configurado}\n year {configurado}\n other {procesado}\n} correctamente.\n\nDetalles de la donación:\nImporte: {amount} {currency} {interval, select,\n month {al mes}\n year {al año}\n other {}\n}\n\nEn breve, Stripe te enviará por separado un correo electrónico con el recibo y la factura en PDF. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y gestionar o cancelar tu suscripción}\n year {y gestionar o cancelar tu suscripción}\n other {y gestionar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} se ha {interval, select,\n month {configurado}\n year {configurado}\n other {procesado}\n} correctamente.\n\nDetalles de la donación:\nImporte: {amount} {interval, select,\n month {al mes}\n year {al año}\n other {}\n}\n\nEn breve, Stripe te enviará por separado un correo electrónico con el recibo y la factura en PDF. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y gestionar o cancelar tu suscripción}\n year {y gestionar o cancelar tu suscripción}\n other {y gestionar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
},
"donation_magic_link": {
"subject": "Gestiona tus donaciones a {product_name}",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Kiitos {product_name}-lahjoituksestasi",
"body": "Hei,\n\nKiitos {product_name}-lahjoituksestasi! {interval, select,\n month {Toistuva lahjoituksesi}\n year {Toistuva lahjoituksesi}\n other {Kertalahjoituksesi}\n} on {interval, select,\n month {käynnistetty}\n year {käynnistetty}\n other {käsitelty}\n} onnistuneesti.\n\nLahjoituksen tiedot:\nSumma: {amount} {currency} {interval, select,\n month {kuukaudessa}\n year {vuodessa}\n other {}\n}\n\nStripe lähettää sinulle pian sähköpostitse erillisen kuitin sekä PDF-laskun. Niissä ovat kaikki maksutiedot, ja niitä voi käyttää verotuksessa.\n\nVoit tarkastella lahjoitushistoriaasi, ladata laskuja {interval, select,\n month {ja hallinnoida tai peruuttaa tilaustasi}\n year {ja hallinnoida tai peruuttaa tilaustasi}\n other {ja hallita tulevia lahjoituksia}\n} milloin tahansa käyttämällä tätä linkkiä:\n\n{manageUrl}\n\nTukesi auttaa pitämään {product_name}-palvelun toiminnassa. Kiitos!\n\n– {product_name}-tiimi"
"body": "Hei,\n\nKiitos {product_name}-lahjoituksestasi! {interval, select,\n month {Toistuva lahjoituksesi}\n year {Toistuva lahjoituksesi}\n other {Kertalahjoituksesi}\n} on {interval, select,\n month {käynnistetty}\n year {käynnistetty}\n other {käsitelty}\n} onnistuneesti.\n\nLahjoituksen tiedot:\nSumma: {amount} {interval, select,\n month {kuukaudessa}\n year {vuodessa}\n other {}\n}\n\nStripe lähettää sinulle pian sähköpostitse erillisen kuitin sekä PDF-laskun. Niissä ovat kaikki maksutiedot, ja niitä voi käyttää verotuksessa.\n\nVoit tarkastella lahjoitushistoriaasi, ladata laskuja {interval, select,\n month {ja hallinnoida tai peruuttaa tilaustasi}\n year {ja hallinnoida tai peruuttaa tilaustasi}\n other {ja hallita tulevia lahjoituksia}\n} milloin tahansa käyttämällä tätä linkkiä:\n\n{manageUrl}\n\nTukesi auttaa pitämään {product_name}-palvelun toiminnassa. Kiitos!\n\n– {product_name}-tiimi"
},
"donation_magic_link": {
"subject": "Hallitse {product_name}-lahjoituksiasi",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Merci pour votre don à {product_name}",
"body": "Bonjour,\n\nMerci pour votre don à {product_name} ! {interval, select, month {Votre don récurrent a bien été mis en place.} year {Votre don récurrent a bien été mis en place.} other {Votre don ponctuel a bien été traité.}}\n\nDétails du don :\nMontant : {amount} {currency} {interval, select, month {par mois} year {par an} other {}}\n\nStripe vous enverra bientôt un reçu séparé accompagné de votre facture PDF. Ce document contient tous les détails du paiement et peut servir de justificatif fiscal.\n\nVous pouvez consulter l’historique de vos dons, télécharger des factures {interval, select, month {et gérer ou résilier votre abonnement} year {et gérer ou résilier votre abonnement} other {et gérer vos futurs dons}} à tout moment avec ce lien :\n\n{manageUrl}\n\nVotre soutien contribue au fonctionnement de {product_name}. Merci !\n\n– L’équipe {product_name}"
"body": "Bonjour,\n\nMerci pour votre don à {product_name} ! {interval, select, month {Votre don récurrent a bien été mis en place.} year {Votre don récurrent a bien été mis en place.} other {Votre don ponctuel a bien été traité.}}\n\nDétails du don :\nMontant : {amount} {interval, select, month {par mois} year {par an} other {}}\n\nStripe vous enverra bientôt un reçu séparé accompagné de votre facture PDF. Ce document contient tous les détails du paiement et peut servir de justificatif fiscal.\n\nVous pouvez consulter l’historique de vos dons, télécharger des factures {interval, select, month {et gérer ou résilier votre abonnement} year {et gérer ou résilier votre abonnement} other {et gérer vos futurs dons}} à tout moment avec ce lien :\n\n{manageUrl}\n\nVotre soutien contribue au fonctionnement de {product_name}. Merci !\n\n– L’équipe {product_name}"
},
"donation_magic_link": {
"subject": "Gérer vos dons à {product_name}",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "תודה על תרומתכם ל-{product_name}",
"body": "שלום,\n\nתודה על תרומתכם ל-{product_name}! ה{interval, select,\n month {תרומה החוזרת}\n year {תרומה החוזרת}\n other {תרומה החד-פעמית}\n} שלכם {interval, select,\n month {הוגדרה}\n year {הוגדרה}\n other {בוצעה}\n} בהצלחה.\n\nפרטי התרומה:\nסכום: {amount} {currency} {interval, select,\n month {לחודש}\n year {לשנה}\n other {}\n}\n\nStripe ישלח לכם בקרוב קבלה נפרדת עם קובץ PDF של החשבונית. הקבלה תכלול את כל פרטי התשלום וניתן יהיה להשתמש בה למטרות מס.\n\nאתם יכולים לצפות בהיסטוריית התרומות שלכם, להוריד חשבוניות, {interval, select,\n month {ולנהל או לבטל את המנוי}\n year {ולנהל או לבטל את המנוי}\n other {ולנהל תרומות עתידיות}\n} בכל עת באמצעות קישור זה:\n\n{manageUrl}\n\nהתמיכה שלכם עוזרת לשמור על פעילות {product_name}. תודה!\n\nצוות {product_name}"
"body": "שלום,\n\nתודה על תרומתכם ל-{product_name}! ה{interval, select,\n month {תרומה החוזרת}\n year {תרומה החוזרת}\n other {תרומה החד-פעמית}\n} שלכם {interval, select,\n month {הוגדרה}\n year {הוגדרה}\n other {בוצעה}\n} בהצלחה.\n\nפרטי התרומה:\nסכום: {amount} {interval, select,\n month {לחודש}\n year {לשנה}\n other {}\n}\n\nStripe ישלח לכם בקרוב קבלה נפרדת עם קובץ PDF של החשבונית. הקבלה תכלול את כל פרטי התשלום וניתן יהיה להשתמש בה למטרות מס.\n\nאתם יכולים לצפות בהיסטוריית התרומות שלכם, להוריד חשבוניות, {interval, select,\n month {ולנהל או לבטל את המנוי}\n year {ולנהל או לבטל את המנוי}\n other {ולנהל תרומות עתידיות}\n} בכל עת באמצעות קישור זה:\n\n{manageUrl}\n\nהתמיכה שלכם עוזרת לשמור על פעילות {product_name}. תודה!\n\nצוות {product_name}"
},
"donation_magic_link": {
"subject": "ניהול התרומות שלכם ל-{product_name}",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "आपके {product_name} डोनेशन के लिए धन्यवाद",
"body": "नमस्ते,\n\n{product_name} को आपके डोनेशन के लिए धन्यवाद! आपका {interval, select,\n month {रिकरिंग डोनेशन}\n year {रिकरिंग डोनेशन}\n other {वन-टाइम डोनेशन}\n} सफलतापूर्वक {interval, select,\n month {सेट अप हो गया है}\n year {सेट अप हो गया है}\n other {प्रोसेस हो गया है}\n}।\n\nडोनेशन का विवरण:\nराशि: {amount} {currency} {interval, select,\n month {प्रति माह}\n year {प्रति वर्ष}\n other {}\n}\n\nStripe आपको जल्द ही आपके इनवॉइस PDF के साथ एक अलग रसीद ईमेल करेगा। इसमें पेमेंट की सारी जानकारी शामिल है और इसे टैक्स के लिए इस्तेमाल किया जा सकता है।\n\nआप इस लिंक से किसी भी समय अपने डोनेशन का इतिहास देख सकते हैं, इनवॉइस डाउनलोड कर सकते हैं, {interval, select,\n month {और अपना सब्सक्रिप्शन मैनेज या कैंसल कर सकते हैं}\n year {और अपना सब्सक्रिप्शन मैनेज या कैंसल कर सकते हैं}\n other {और आगे के डोनेशन मैनेज कर सकते हैं}\n}।\n\n{manageUrl}\n\nआपका सपोर्ट {product_name} को चालू रखने में मदद करता है। धन्यवाद!\n\n– {product_name} टीम"
"body": "नमस्ते,\n\n{product_name} को आपके डोनेशन के लिए धन्यवाद! आपका {interval, select,\n month {रिकरिंग डोनेशन}\n year {रिकरिंग डोनेशन}\n other {वन-टाइम डोनेशन}\n} सफलतापूर्वक {interval, select,\n month {सेट अप हो गया है}\n year {सेट अप हो गया है}\n other {प्रोसेस हो गया है}\n}।\n\nडोनेशन का विवरण:\nराशि: {amount} {interval, select,\n month {प्रति माह}\n year {प्रति वर्ष}\n other {}\n}\n\nStripe आपको जल्द ही आपके इनवॉइस PDF के साथ एक अलग रसीद ईमेल करेगा। इसमें पेमेंट की सारी जानकारी शामिल है और इसे टैक्स के लिए इस्तेमाल किया जा सकता है।\n\nआप इस लिंक से किसी भी समय अपने डोनेशन का इतिहास देख सकते हैं, इनवॉइस डाउनलोड कर सकते हैं, {interval, select,\n month {और अपना सब्सक्रिप्शन मैनेज या कैंसल कर सकते हैं}\n year {और अपना सब्सक्रिप्शन मैनेज या कैंसल कर सकते हैं}\n other {और आगे के डोनेशन मैनेज कर सकते हैं}\n}।\n\n{manageUrl}\n\nआपका सपोर्ट {product_name} को चालू रखने में मदद करता है। धन्यवाद!\n\n– {product_name} टीम"
},
"donation_magic_link": {
"subject": "अपने {product_name} डोनेशन मैनेज करें",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Hvala ti na donaciji za {product_name}",
"body": "Pozdrav,\n\nHvala ti na donaciji za {product_name}! Tvoja {interval, select,\n month {redovita donacija}\n year {redovita donacija}\n other {jednokratna donacija}\n} uspješno je {interval, select,\n month {postavljena}\n year {postavljena}\n other {obrađena}\n}.\n\nDetalji donacije:\nIznos: {amount} {currency} {interval, select,\n month {mjesečno}\n year {godišnje}\n other {}\n}\n\nStripe će ti uskoro poslati zasebnu potvrdu s PDF-om računa. Ona sadrži sve detalje plaćanja i možeš je koristiti u porezne svrhe.\n\nMožeš pregledati povijest donacija, preuzeti račune {interval, select,\n month {i upravljati pretplatom ili je otkazati}\n year {i upravljati pretplatom ili je otkazati}\n other {i upravljati budućim donacijama}\n} u bilo kojem trenutku putem ove poveznice:\n\n{manageUrl}\n\nTvoja podrška pomaže da {product_name} nastavi raditi. Hvala ti!\n\n– Tim {product_name}a"
"body": "Pozdrav,\n\nHvala ti na donaciji za {product_name}! Tvoja {interval, select,\n month {redovita donacija}\n year {redovita donacija}\n other {jednokratna donacija}\n} uspješno je {interval, select,\n month {postavljena}\n year {postavljena}\n other {obrađena}\n}.\n\nDetalji donacije:\nIznos: {amount} {interval, select,\n month {mjesečno}\n year {godišnje}\n other {}\n}\n\nStripe će ti uskoro poslati zasebnu potvrdu s PDF-om računa. Ona sadrži sve detalje plaćanja i možeš je koristiti u porezne svrhe.\n\nMožeš pregledati povijest donacija, preuzeti račune {interval, select,\n month {i upravljati pretplatom ili je otkazati}\n year {i upravljati pretplatom ili je otkazati}\n other {i upravljati budućim donacijama}\n} u bilo kojem trenutku putem ove poveznice:\n\n{manageUrl}\n\nTvoja podrška pomaže da {product_name} nastavi raditi. Hvala ti!\n\n– Tim {product_name}a"
},
"donation_magic_link": {
"subject": "Upravljanje donacijama za {product_name}",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Köszönjük, hogy adományoddal támogatod a {product_name} működését",
"body": "Szia!\n\nKöszönjük, hogy adományoddal támogatod a {product_name} működését! {interval, select,\n month {Sikeresen beállítottuk a rendszeres adományodat.}\n year {Sikeresen beállítottuk a rendszeres adományodat.}\n other {Sikeresen feldolgoztuk az egyszeri adományodat.}\n}\n\nAz adomány részletei:\nÖsszeg: {amount} {currency} {interval, select,\n month {havonta}\n year {évente}\n other {}\n}\n\nA Stripe hamarosan külön e-mailben küld nyugtát és egy PDF-számlát. Ezek tartalmazzák az összes fizetési adatot, és adózási célokra is használhatók.\n\nAz alábbi linken bármikor megtekintheted korábbi adományaidat, letöltheted a számlákat, {interval, select,\n month {valamint kezelheted vagy lemondhatod az előfizetésedet}\n year {valamint kezelheted vagy lemondhatod az előfizetésedet}\n other {valamint kezelheted a jövőbeli adományaidat}\n}:\n\n{manageUrl}\n\nTámogatásod segít fenntartani a {product_name} működését. Köszönjük!\n\n– {product_name} csapata"
"body": "Szia!\n\nKöszönjük, hogy adományoddal támogatod a {product_name} működését! {interval, select,\n month {Sikeresen beállítottuk a rendszeres adományodat.}\n year {Sikeresen beállítottuk a rendszeres adományodat.}\n other {Sikeresen feldolgoztuk az egyszeri adományodat.}\n}\n\nAz adomány részletei:\nÖsszeg: {amount} {interval, select,\n month {havonta}\n year {évente}\n other {}\n}\n\nA Stripe hamarosan külön e-mailben küld nyugtát és egy PDF-számlát. Ezek tartalmazzák az összes fizetési adatot, és adózási célokra is használhatók.\n\nAz alábbi linken bármikor megtekintheted korábbi adományaidat, letöltheted a számlákat, {interval, select,\n month {valamint kezelheted vagy lemondhatod az előfizetésedet}\n year {valamint kezelheted vagy lemondhatod az előfizetésedet}\n other {valamint kezelheted a jövőbeli adományaidat}\n}:\n\n{manageUrl}\n\nTámogatásod segít fenntartani a {product_name} működését. Köszönjük!\n\n– {product_name} csapata"
},
"donation_magic_link": {
"subject": "A {product_name} működését támogató adományaid kezelése",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Terima kasih atas donasimu untuk {product_name}",
"body": "Halo,\n\nTerima kasih atas donasimu untuk {product_name}! Donasi {interval, select,\n month {berulang}\n year {berulang}\n other {satu kali}\n} kamu telah berhasil {interval, select,\n month {diatur}\n year {diatur}\n other {diproses}\n}.\n\nDetail donasi:\nJumlah: {amount} {currency} {interval, select,\n month {per bulan}\n year {per tahun}\n other {}\n}\n\nStripe akan segera mengirimi kamu email tanda terima terpisah beserta PDF fakturmu. Ini mencakup semua detail pembayaran dan bisa dipakai untuk keperluan pajak.\n\nKamu bisa melihat riwayat donasimu, mengunduh faktur, {interval, select,\n month {dan mengelola atau membatalkan langgananmu}\n year {dan mengelola atau membatalkan langgananmu}\n other {dan mengelola donasi di masa mendatang}\n} kapan saja lewat tautan ini:\n\n{manageUrl}\n\nDukunganmu membantu {product_name} tetap berjalan. Terima kasih!\n\n– Tim {product_name}"
"body": "Halo,\n\nTerima kasih atas donasimu untuk {product_name}! Donasi {interval, select,\n month {berulang}\n year {berulang}\n other {satu kali}\n} kamu telah berhasil {interval, select,\n month {diatur}\n year {diatur}\n other {diproses}\n}.\n\nDetail donasi:\nJumlah: {amount} {interval, select,\n month {per bulan}\n year {per tahun}\n other {}\n}\n\nStripe akan segera mengirimi kamu email tanda terima terpisah beserta PDF fakturmu. Ini mencakup semua detail pembayaran dan bisa dipakai untuk keperluan pajak.\n\nKamu bisa melihat riwayat donasimu, mengunduh faktur, {interval, select,\n month {dan mengelola atau membatalkan langgananmu}\n year {dan mengelola atau membatalkan langgananmu}\n other {dan mengelola donasi di masa mendatang}\n} kapan saja lewat tautan ini:\n\n{manageUrl}\n\nDukunganmu membantu {product_name} tetap berjalan. Terima kasih!\n\n– Tim {product_name}"
},
"donation_magic_link": {
"subject": "Kelola donasimu untuk {product_name}",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Grazie per la tua donazione a {product_name}",
"body": "Ciao,\n\nGrazie per la tua donazione a {product_name}! La tua {interval, select,\n month {donazione ricorrente}\n year {donazione ricorrente}\n other {donazione una tantum}\n} è stata {interval, select,\n month {attivata}\n year {attivata}\n other {elaborata}\n} con successo.\n\nDettagli della donazione:\nImporto: {amount} {currency} {interval, select,\n month {al mese}\n year {all'anno}\n other {}\n}\n\nStripe ti invierà a breve una ricevuta separata con la tua fattura in PDF. Questa include tutti i dettagli del pagamento e può essere utilizzata a fini fiscali.\n\nPuoi visualizzare la cronologia delle tue donazioni, scaricare le fatture {interval, select,\n month {e gestire o disdire il tuo abbonamento}\n year {e gestire o disdire il tuo abbonamento}\n other {e gestire le future donazioni}\n} in qualsiasi momento utilizzando questo link:\n\n{manageUrl}\n\nIl tuo supporto aiuta a mantenere {product_name} in funzione. Grazie!\n\n– Il team di {product_name}"
"body": "Ciao,\n\nGrazie per la tua donazione a {product_name}! La tua {interval, select,\n month {donazione ricorrente}\n year {donazione ricorrente}\n other {donazione una tantum}\n} è stata {interval, select,\n month {attivata}\n year {attivata}\n other {elaborata}\n} con successo.\n\nDettagli della donazione:\nImporto: {amount} {interval, select,\n month {al mese}\n year {all'anno}\n other {}\n}\n\nStripe ti invierà a breve una ricevuta separata con la tua fattura in PDF. Questa include tutti i dettagli del pagamento e può essere utilizzata a fini fiscali.\n\nPuoi visualizzare la cronologia delle tue donazioni, scaricare le fatture {interval, select,\n month {e gestire o disdire il tuo abbonamento}\n year {e gestire o disdire il tuo abbonamento}\n other {e gestire le future donazioni}\n} in qualsiasi momento utilizzando questo link:\n\n{manageUrl}\n\nIl tuo supporto aiuta a mantenere {product_name} in funzione. Grazie!\n\n– Il team di {product_name}"
},
"donation_magic_link": {
"subject": "Gestisci le tue donazioni a {product_name}",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "{product_name}へのご寄付ありがとうございます",
"body": "こんにちは。\n\n{product_name}へのご寄付ありがとうございます。{interval, select,\n month {定期寄付}\n year {定期寄付}\n other {1回のみの寄付}\n}の{interval, select,\n month {設定}\n year {設定}\n other {処理}\n}が完了しました。\n\n寄付の詳細:\n金額: {amount} {currency}{interval, select,\n month {(毎月)}\n year {(毎年)}\n other {}\n}\n\nStripeから、請求書PDF付きの領収書が別途メールで送信されます。これにはすべての支払い詳細が含まれており、税務目的にご利用いただけます。\n\n以下のリンクから、いつでも寄付履歴の表示、請求書のダウンロード、{interval, select,\n month {およびサブスクリプションの管理またはキャンセル}\n year {およびサブスクリプションの管理またはキャンセル}\n other {および今後の寄付の管理}\n}が可能です。\n\n{manageUrl}\n\n皆様のご支援が{product_name}の運営を支えています。ありがとうございます。\n\n– {product_name}チーム"
"body": "こんにちは。\n\n{product_name}へのご寄付ありがとうございます。{interval, select,\n month {定期寄付}\n year {定期寄付}\n other {1回のみの寄付}\n}の{interval, select,\n month {設定}\n year {設定}\n other {処理}\n}が完了しました。\n\n寄付の詳細:\n金額: {amount}{interval, select,\n month {(毎月)}\n year {(毎年)}\n other {}\n}\n\nStripeから、請求書PDF付きの領収書が別途メールで送信されます。これにはすべての支払い詳細が含まれており、税務目的にご利用いただけます。\n\n以下のリンクから、いつでも寄付履歴の表示、請求書のダウンロード、{interval, select,\n month {およびサブスクリプションの管理またはキャンセル}\n year {およびサブスクリプションの管理またはキャンセル}\n other {および今後の寄付の管理}\n}が可能です。\n\n{manageUrl}\n\n皆様のご支援が{product_name}の運営を支えています。ありがとうございます。\n\n– {product_name}チーム"
},
"donation_magic_link": {
"subject": "{product_name}への寄付を管理",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "{product_name} 후원에 감사드려요",
"body": "안녕하세요,\n\n{product_name}에 후원해 주셔서 감사해요! 회원님의 {interval, select,\n month {정기 후원}\n year {정기 후원}\n other {일회성 후원}\n}이 성공적으로 {interval, select,\n month {설정되었어요}\n year {설정되었어요}\n other {처리되었어요}\n}.\n\n후원 상세 정보:\n금액: {amount} {currency} {interval, select,\n month {매월}\n year {매년}\n other {}\n}\n\nStripe에서 곧 인보이스 PDF가 포함된 별도의 영수증을 이메일로 보내드릴 예정이에요. 이 영수증에는 모든 결제 세부 정보가 포함되어 있으며, 세금 목적으로 활용할 수 있어요.\n\n다음 링크를 통해 언제든지 후원 내역을 확인하고, 인보이스를 다운로드하고, {interval, select,\n month {구독을 관리하거나 취소할 수 있어요}\n year {구독을 관리하거나 취소할 수 있어요}\n other {향후 후원을 관리할 수 있어요}\n}:\n\n{manageUrl}\n\n회원님의 후원은 {product_name}를 계속 운영하는 데 큰 도움이 돼요. 진심으로 감사해요!\n\n– {product_name} 팀"
"body": "안녕하세요,\n\n{product_name}에 후원해 주셔서 감사해요! 회원님의 {interval, select,\n month {정기 후원}\n year {정기 후원}\n other {일회성 후원}\n}이 성공적으로 {interval, select,\n month {설정되었어요}\n year {설정되었어요}\n other {처리되었어요}\n}.\n\n후원 상세 정보:\n금액: {amount} {interval, select,\n month {매월}\n year {매년}\n other {}\n}\n\nStripe에서 곧 인보이스 PDF가 포함된 별도의 영수증을 이메일로 보내드릴 예정이에요. 이 영수증에는 모든 결제 세부 정보가 포함되어 있으며, 세금 목적으로 활용할 수 있어요.\n\n다음 링크를 통해 언제든지 후원 내역을 확인하고, 인보이스를 다운로드하고, {interval, select,\n month {구독을 관리하거나 취소할 수 있어요}\n year {구독을 관리하거나 취소할 수 있어요}\n other {향후 후원을 관리할 수 있어요}\n}:\n\n{manageUrl}\n\n회원님의 후원은 {product_name}를 계속 운영하는 데 큰 도움이 돼요. 진심으로 감사해요!\n\n– {product_name} 팀"
},
"donation_magic_link": {
"subject": "{product_name} 후원 내역 관리",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Dėkojame už tavo {product_name} paramą",
"body": "Sveiki,\n\nDėkojame už tavo paramą {product_name}! Tavo {interval, select,\n month {pasikartojanti parama}\n year {pasikartojanti parama}\n other {vienkartinė parama}\n} sėkmingai {interval, select,\n month {nustatyta}\n year {nustatyta}\n other {apdorota}\n}.\n\nParamos informacija:\nSuma: {amount} {currency} {interval, select,\n month {per mėnesį}\n year {per metus}\n other {}\n}\n\nStripe netrukus atsiųs tau atskirą kvitą su sąskaitos faktūros PDF. Jame nurodyta visa mokėjimo informacija ir jį galima naudoti mokesčių tikslais.\n\nBet kuriuo metu šia nuoroda gali peržiūrėti savo paramos istoriją, atsisiųsti sąskaitas faktūras {interval, select,\n month {ir valdyti arba atšaukti savo prenumeratą}\n year {ir valdyti arba atšaukti savo prenumeratą}\n other {ir valdyti būsimą paramą}\n}:\n\n{manageUrl}\n\nTavo parama padeda {product_name} veikti. Ačiū!\n\n– {product_name} komanda"
"body": "Sveiki,\n\nDėkojame už tavo paramą {product_name}! Tavo {interval, select,\n month {pasikartojanti parama}\n year {pasikartojanti parama}\n other {vienkartinė parama}\n} sėkmingai {interval, select,\n month {nustatyta}\n year {nustatyta}\n other {apdorota}\n}.\n\nParamos informacija:\nSuma: {amount} {interval, select,\n month {per mėnesį}\n year {per metus}\n other {}\n}\n\nStripe netrukus atsiųs tau atskirą kvitą su sąskaitos faktūros PDF. Jame nurodyta visa mokėjimo informacija ir jį galima naudoti mokesčių tikslais.\n\nBet kuriuo metu šia nuoroda gali peržiūrėti savo paramos istoriją, atsisiųsti sąskaitas faktūras {interval, select,\n month {ir valdyti arba atšaukti savo prenumeratą}\n year {ir valdyti arba atšaukti savo prenumeratą}\n other {ir valdyti būsimą paramą}\n}:\n\n{manageUrl}\n\nTavo parama padeda {product_name} veikti. Ačiū!\n\n– {product_name} komanda"
},
"donation_magic_link": {
"subject": "Tvarkyk savo {product_name} paramą",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Bedankt voor je {product_name}-donatie",
"body": "Hallo,\n\nBedankt voor je donatie aan {product_name}! Je {interval, select,\n month {terugkerende donatie}\n year {terugkerende donatie}\n other {eenmalige donatie}\n} is succesvol {interval, select,\n month {ingesteld}\n year {ingesteld}\n other {verwerkt}\n}.\n\nDonatiegegevens:\nBedrag: {amount} {currency} {interval, select,\n month {per maand}\n year {per jaar}\n other {}\n}\n\nStripe stuurt je binnenkort per e-mail een apart betalingsbewijs met je factuur als pdf. Dit bevat alle betalingsgegevens en kan worden gebruikt voor belastingdoeleinden.\n\nJe kunt op elk moment je donatiegeschiedenis bekijken, facturen downloaden en {interval, select,\n month {je abonnement beheren of opzeggen}\n year {je abonnement beheren of opzeggen}\n other {toekomstige donaties beheren}\n} via deze link:\n\n{manageUrl}\n\nJe steun helpt {product_name} draaiende te houden. Bedankt!\n\n– Het team van {product_name}"
"body": "Hallo,\n\nBedankt voor je donatie aan {product_name}! Je {interval, select,\n month {terugkerende donatie}\n year {terugkerende donatie}\n other {eenmalige donatie}\n} is succesvol {interval, select,\n month {ingesteld}\n year {ingesteld}\n other {verwerkt}\n}.\n\nDonatiegegevens:\nBedrag: {amount} {interval, select,\n month {per maand}\n year {per jaar}\n other {}\n}\n\nStripe stuurt je binnenkort per e-mail een apart betalingsbewijs met je factuur als pdf. Dit bevat alle betalingsgegevens en kan worden gebruikt voor belastingdoeleinden.\n\nJe kunt op elk moment je donatiegeschiedenis bekijken, facturen downloaden en {interval, select,\n month {je abonnement beheren of opzeggen}\n year {je abonnement beheren of opzeggen}\n other {toekomstige donaties beheren}\n} via deze link:\n\n{manageUrl}\n\nJe steun helpt {product_name} draaiende te houden. Bedankt!\n\n– Het team van {product_name}"
},
"donation_magic_link": {
"subject": "Beheer je {product_name}-donaties",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Takk for bidraget ditt til {product_name}",
"body": "Hei,\n\nTakk for bidraget ditt til {product_name}! {interval, select,\n month {Det faste bidraget ditt er opprettet.}\n year {Det faste bidraget ditt er opprettet.}\n other {Engangsbidraget ditt er behandlet.}\n}\n\nDetaljer om bidraget:\nBeløp: {amount} {currency} {interval, select,\n month {per måned}\n year {per år}\n other {}\n}\n\nStripe sender deg snart en egen kvittering med fakturaen som PDF. Kvitteringen inneholder alle betalingsdetaljer og kan brukes til skatteformål.\n\nDu kan når som helst se bidragshistorikken din, laste ned fakturaer og {interval, select,\n month {administrere eller si opp abonnementet ditt}\n year {administrere eller si opp abonnementet ditt}\n other {administrere framtidige bidrag}\n} via denne lenken:\n\n{manageUrl}\n\nStøtten din bidrar til å holde {product_name} i gang. Takk!\n\n– {product_name}-teamet"
"body": "Hei,\n\nTakk for bidraget ditt til {product_name}! {interval, select,\n month {Det faste bidraget ditt er opprettet.}\n year {Det faste bidraget ditt er opprettet.}\n other {Engangsbidraget ditt er behandlet.}\n}\n\nDetaljer om bidraget:\nBeløp: {amount} {interval, select,\n month {per måned}\n year {per år}\n other {}\n}\n\nStripe sender deg snart en egen kvittering med fakturaen som PDF. Kvitteringen inneholder alle betalingsdetaljer og kan brukes til skatteformål.\n\nDu kan når som helst se bidragshistorikken din, laste ned fakturaer og {interval, select,\n month {administrere eller si opp abonnementet ditt}\n year {administrere eller si opp abonnementet ditt}\n other {administrere framtidige bidrag}\n} via denne lenken:\n\n{manageUrl}\n\nStøtten din bidrar til å holde {product_name} i gang. Takk!\n\n– {product_name}-teamet"
},
"donation_magic_link": {
"subject": "Administrer {product_name}-bidragene dine",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Dziękujemy za darowiznę dla {product_name}",
"body": "Witaj,\n\nDziękujemy za darowiznę dla {product_name}! Twoja {interval, select,\n month {cykliczna darowizna}\n year {cykliczna darowizna}\n other {jednorazowa darowizna}\n} została {interval, select,\n month {uruchomiona}\n year {uruchomiona}\n other {przetworzona}\n} pomyślnie.\n\nSzczegóły darowizny:\nKwota: {amount} {currency} {interval, select,\n month {miesięcznie}\n year {rocznie}\n other {}\n}\n\nStripe wkrótce wyśle Ci osobną wiadomość e-mail z potwierdzeniem i fakturą w formacie PDF. Potwierdzenie zawiera wszystkie szczegóły płatności i może służyć do celów podatkowych.\n\nMożesz przeglądać historię swoich darowizn, pobierać faktury {interval, select,\n month {oraz zarządzać subskrypcją lub ją anulować}\n year {oraz zarządzać subskrypcją lub ją anulować}\n other {oraz zarządzać przyszłymi darowiznami}\n} w dowolnym momencie, korzystając z tego linku:\n\n{manageUrl}\n\nTwoje wsparcie pomaga utrzymać {product_name}. Dziękujemy!\n\n– Zespół {product_name}"
"body": "Witaj,\n\nDziękujemy za darowiznę dla {product_name}! Twoja {interval, select,\n month {cykliczna darowizna}\n year {cykliczna darowizna}\n other {jednorazowa darowizna}\n} została {interval, select,\n month {uruchomiona}\n year {uruchomiona}\n other {przetworzona}\n} pomyślnie.\n\nSzczegóły darowizny:\nKwota: {amount} {interval, select,\n month {miesięcznie}\n year {rocznie}\n other {}\n}\n\nStripe wkrótce wyśle Ci osobną wiadomość e-mail z potwierdzeniem i fakturą w formacie PDF. Potwierdzenie zawiera wszystkie szczegóły płatności i może służyć do celów podatkowych.\n\nMożesz przeglądać historię swoich darowizn, pobierać faktury {interval, select,\n month {oraz zarządzać subskrypcją lub ją anulować}\n year {oraz zarządzać subskrypcją lub ją anulować}\n other {oraz zarządzać przyszłymi darowiznami}\n} w dowolnym momencie, korzystając z tego linku:\n\n{manageUrl}\n\nTwoje wsparcie pomaga utrzymać {product_name}. Dziękujemy!\n\n– Zespół {product_name}"
},
"donation_magic_link": {
"subject": "Zarządzaj darowiznami dla {product_name}",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Agradecemos pela sua doação ao {product_name}",
"body": "Olá,\n\nAgradecemos pela sua doação ao {product_name}! Sua {interval, select,\n month {doação recorrente}\n year {doação recorrente}\n other {doação única}\n} foi {interval, select,\n month {configurada}\n year {configurada}\n other {processada}\n} com sucesso.\n\nDetalhes da doação:\nValor: {amount} {currency} {interval, select,\n month {por mês}\n year {por ano}\n other {}\n}\n\nA Stripe enviará um recibo separado com sua fatura em PDF em breve. Isso inclui todos os detalhes de pagamento e pode ser usado para fins fiscais.\n\nVocê pode visualizar seu histórico de doações, baixar faturas {interval, select,\n month {e gerenciar ou cancelar sua assinatura}\n year {e gerenciar ou cancelar sua assinatura}\n other {e gerenciar futuras doações}\n} a qualquer momento usando este link:\n\n{manageUrl}\n\nSeu apoio ajuda a manter o {product_name} funcionando. Agradecemos!\n\n– Equipe do {product_name}"
"body": "Olá,\n\nAgradecemos pela sua doação ao {product_name}! Sua {interval, select,\n month {doação recorrente}\n year {doação recorrente}\n other {doação única}\n} foi {interval, select,\n month {configurada}\n year {configurada}\n other {processada}\n} com sucesso.\n\nDetalhes da doação:\nValor: {amount} {interval, select,\n month {por mês}\n year {por ano}\n other {}\n}\n\nA Stripe enviará um recibo separado com sua fatura em PDF em breve. Isso inclui todos os detalhes de pagamento e pode ser usado para fins fiscais.\n\nVocê pode visualizar seu histórico de doações, baixar faturas {interval, select,\n month {e gerenciar ou cancelar sua assinatura}\n year {e gerenciar ou cancelar sua assinatura}\n other {e gerenciar futuras doações}\n} a qualquer momento usando este link:\n\n{manageUrl}\n\nSeu apoio ajuda a manter o {product_name} funcionando. Agradecemos!\n\n– Equipe do {product_name}"
},
"donation_magic_link": {
"subject": "Gerencie suas doações ao {product_name}",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Îți mulțumim pentru donația către {product_name}",
"body": "Salut,\n\nÎți mulțumim pentru donația ta către {product_name}! {interval, select,\n month {Donația ta recurentă}\n year {Donația ta recurentă}\n other {Donația ta unică}\n} a fost {interval, select,\n month {configurată}\n year {configurată}\n other {procesată}\n} cu succes.\n\nDetalii donație:\nSumă: {amount} {currency} {interval, select,\n month {pe lună}\n year {pe an}\n other {}\n}\n\nStripe îți va trimite în scurt timp un e-mail separat cu chitanța și factura ta în format PDF. Acestea includ toate detaliile plății și pot fi folosite în scopuri fiscale.\n\nPoți vizualiza istoricul donațiilor, descărca facturi {interval, select,\n month {și gestiona sau anula abonamentul}\n year {și gestiona sau anula abonamentul}\n other {și gestiona donațiile viitoare}\n} în orice moment folosind acest link:\n\n{manageUrl}\n\nSprijinul tău ne ajută să menținem {product_name} în funcțiune. Mulțumim!\n\n– Echipa {product_name}"
"body": "Salut,\n\nÎți mulțumim pentru donația ta către {product_name}! {interval, select,\n month {Donația ta recurentă}\n year {Donația ta recurentă}\n other {Donația ta unică}\n} a fost {interval, select,\n month {configurată}\n year {configurată}\n other {procesată}\n} cu succes.\n\nDetalii donație:\nSumă: {amount} {interval, select,\n month {pe lună}\n year {pe an}\n other {}\n}\n\nStripe îți va trimite în scurt timp un e-mail separat cu chitanța și factura ta în format PDF. Acestea includ toate detaliile plății și pot fi folosite în scopuri fiscale.\n\nPoți vizualiza istoricul donațiilor, descărca facturi {interval, select,\n month {și gestiona sau anula abonamentul}\n year {și gestiona sau anula abonamentul}\n other {și gestiona donațiile viitoare}\n} în orice moment folosind acest link:\n\n{manageUrl}\n\nSprijinul tău ne ajută să menținem {product_name} în funcțiune. Mulțumim!\n\n– Echipa {product_name}"
},
"donation_magic_link": {
"subject": "Gestionează-ți donațiile către {product_name}",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Спасибо за твоё пожертвование для {product_name}",
"body": "Привет,\n\nСпасибо за твоё пожертвование для {product_name}! Твоё {interval, select,\n month {регулярное пожертвование}\n year {регулярное пожертвование}\n other {разовое пожертвование}\n} успешно {interval, select,\n month {настроено}\n year {настроено}\n other {обработано}\n}.\n\nДетали пожертвования:\nСумма: {amount} {currency} {interval, select,\n month {в месяц}\n year {в год}\n other {}\n}\n\nStripe вскоре отправит тебе отдельную квитанцию со счётом в формате PDF. В ней есть все платёжные данные, и её можно использовать для налоговых целей.\n\nТы можешь просматривать историю своих пожертвований, скачивать счета, {interval, select,\n month {а также управлять подпиской или отменить её}\n year {а также управлять подпиской или отменить её}\n other {и управлять будущими пожертвованиями}\n} в любое время по этой ссылке:\n\n{manageUrl}\n\nТвоя поддержка помогает {product_name} работать. Спасибо!\n\n– Команда {product_name}"
"body": "Привет,\n\nСпасибо за твоё пожертвование для {product_name}! Твоё {interval, select,\n month {регулярное пожертвование}\n year {регулярное пожертвование}\n other {разовое пожертвование}\n} успешно {interval, select,\n month {настроено}\n year {настроено}\n other {обработано}\n}.\n\nДетали пожертвования:\nСумма: {amount} {interval, select,\n month {в месяц}\n year {в год}\n other {}\n}\n\nStripe вскоре отправит тебе отдельную квитанцию со счётом в формате PDF. В ней есть все платёжные данные, и её можно использовать для налоговых целей.\n\nТы можешь просматривать историю своих пожертвований, скачивать счета, {interval, select,\n month {а также управлять подпиской или отменить её}\n year {а также управлять подпиской или отменить её}\n other {и управлять будущими пожертвованиями}\n} в любое время по этой ссылке:\n\n{manageUrl}\n\nТвоя поддержка помогает {product_name} работать. Спасибо!\n\n– Команда {product_name}"
},
"donation_magic_link": {
"subject": "Управляй своими пожертвованиями для {product_name}",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Tack för din donation till {product_name}",
"body": "Hej,\n\nTack för din donation till {product_name}! {interval, select,\n month {Din återkommande donation har registrerats.}\n year {Din återkommande donation har registrerats.}\n other {Din engångsdonation har genomförts.}\n}\n\nDonationsuppgifter:\nBelopp: {amount} {currency} {interval, select,\n month {per månad}\n year {per år}\n other {}\n}\n\nStripe skickar snart ett separat kvitto med din faktura i PDF-format via e-post. Där finns alla betalningsuppgifter, och underlaget kan användas för skatteändamål.\n\nDu kan se din donationshistorik, ladda ner fakturor {interval, select,\n month {och hantera eller avsluta din återkommande donation}\n year {och hantera eller avsluta din återkommande donation}\n other {och hantera framtida donationer}\n} när som helst via den här länken:\n\n{manageUrl}\n\nDitt stöd hjälper till att hålla {product_name} igång. Tack!\n\n– {product_name}-teamet"
"body": "Hej,\n\nTack för din donation till {product_name}! {interval, select,\n month {Din återkommande donation har registrerats.}\n year {Din återkommande donation har registrerats.}\n other {Din engångsdonation har genomförts.}\n}\n\nDonationsuppgifter:\nBelopp: {amount} {interval, select,\n month {per månad}\n year {per år}\n other {}\n}\n\nStripe skickar snart ett separat kvitto med din faktura i PDF-format via e-post. Där finns alla betalningsuppgifter, och underlaget kan användas för skatteändamål.\n\nDu kan se din donationshistorik, ladda ner fakturor {interval, select,\n month {och hantera eller avsluta din återkommande donation}\n year {och hantera eller avsluta din återkommande donation}\n other {och hantera framtida donationer}\n} när som helst via den här länken:\n\n{manageUrl}\n\nDitt stöd hjälper till att hålla {product_name} igång. Tack!\n\n– {product_name}-teamet"
},
"donation_magic_link": {
"subject": "Hantera dina donationer till {product_name}",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "ขอบคุณสำหรับการบริจาคให้ {product_name}",
"body": "สวัสดี,\n\nขอบคุณสำหรับการบริจาคให้ {product_name}! {interval, select,\n month {การบริจาคแบบต่อเนื่อง}\n year {การบริจาคแบบต่อเนื่อง}\n other {การบริจาคครั้งเดียว}\n}ของคุณได้รับการ{interval, select,\n month {ตั้งค่า}\n year {ตั้งค่า}\n other {ดำเนินการ}\n}เรียบร้อยแล้ว\n\nรายละเอียดการบริจาค:\nจำนวนเงิน: {amount} {currency} {interval, select,\n month {ต่อเดือน}\n year {ต่อปี}\n other {}\n}\n\nStripe จะส่งอีเมลใบเสร็จแยกต่างหากพร้อมไฟล์ PDF ใบแจ้งหนี้ให้คุณในไม่ช้า ใบเสร็จนี้มีรายละเอียดการชำระเงินทั้งหมดและใช้สำหรับวัตถุประสงค์ทางภาษีได้\n\nคุณสามารถดูประวัติการบริจาค ดาวน์โหลดใบแจ้งหนี้ {interval, select,\n month {และจัดการหรือยกเลิกการสมัครสมาชิกของคุณ}\n year {และจัดการหรือยกเลิกการสมัครสมาชิกของคุณ}\n other {และจัดการการบริจาคในอนาคต}\n} ได้ตลอดเวลาโดยใช้ลิงก์นี้:\n\n{manageUrl}\n\nการสนับสนุนของคุณช่วยให้ {product_name} ดำเนินต่อไปได้ ขอบคุณ!\n\n– ทีม {product_name}"
"body": "สวัสดี,\n\nขอบคุณสำหรับการบริจาคให้ {product_name}! {interval, select,\n month {การบริจาคแบบต่อเนื่อง}\n year {การบริจาคแบบต่อเนื่อง}\n other {การบริจาคครั้งเดียว}\n}ของคุณได้รับการ{interval, select,\n month {ตั้งค่า}\n year {ตั้งค่า}\n other {ดำเนินการ}\n}เรียบร้อยแล้ว\n\nรายละเอียดการบริจาค:\nจำนวนเงิน: {amount} {interval, select,\n month {ต่อเดือน}\n year {ต่อปี}\n other {}\n}\n\nStripe จะส่งอีเมลใบเสร็จแยกต่างหากพร้อมไฟล์ PDF ใบแจ้งหนี้ให้คุณในไม่ช้า ใบเสร็จนี้มีรายละเอียดการชำระเงินทั้งหมดและใช้สำหรับวัตถุประสงค์ทางภาษีได้\n\nคุณสามารถดูประวัติการบริจาค ดาวน์โหลดใบแจ้งหนี้ {interval, select,\n month {และจัดการหรือยกเลิกการสมัครสมาชิกของคุณ}\n year {และจัดการหรือยกเลิกการสมัครสมาชิกของคุณ}\n other {และจัดการการบริจาคในอนาคต}\n} ได้ตลอดเวลาโดยใช้ลิงก์นี้:\n\n{manageUrl}\n\nการสนับสนุนของคุณช่วยให้ {product_name} ดำเนินต่อไปได้ ขอบคุณ!\n\n– ทีม {product_name}"
},
"donation_magic_link": {
"subject": "จัดการการบริจาคให้ {product_name}",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "{product_name}'a yaptığın bağış için teşekkürler",
"body": "Merhaba,\n\n{product_name}'a yaptığın bağış için teşekkürler! {interval, select,\n month {tekrarlayan bağışın}\n year {tekrarlayan bağışın}\n other {tek seferlik bağışın}\n} başarıyla {interval, select,\n month {ayarlandı}\n year {ayarlandı}\n other {işlendi}\n}.\n\nBağış detayları:\nTutar: {amount} {currency} {interval, select,\n month {aylık}\n year {yıllık}\n other {}\n}\n\nStripe, kısa süre içinde fatura PDF'ini içeren ayrı bir makbuzu sana e-posta ile gönderecektir. Bu, tüm ödeme detaylarını içerir ve vergi amaçlı kullanılabilir.\n\nBu bağlantıyı kullanarak istediğin zaman bağış geçmişini görüntüleyebilir, faturaları indirebilir, {interval, select,\n month {ve aboneliğini yönetebilir veya iptal edebilirsin}\n year {ve aboneliğini yönetebilir veya iptal edebilirsin}\n other {ve gelecekteki bağışlarını yönetebilirsin}\n}:\n\n{manageUrl}\n\nDesteğin {product_name}'ın çalışmaya devam etmesine yardımcı oluyor. Teşekkürler!\n\n– {product_name} Ekibi"
"body": "Merhaba,\n\n{product_name}'a yaptığın bağış için teşekkürler! {interval, select,\n month {tekrarlayan bağışın}\n year {tekrarlayan bağışın}\n other {tek seferlik bağışın}\n} başarıyla {interval, select,\n month {ayarlandı}\n year {ayarlandı}\n other {işlendi}\n}.\n\nBağış detayları:\nTutar: {amount} {interval, select,\n month {aylık}\n year {yıllık}\n other {}\n}\n\nStripe, kısa süre içinde fatura PDF'ini içeren ayrı bir makbuzu sana e-posta ile gönderecektir. Bu, tüm ödeme detaylarını içerir ve vergi amaçlı kullanılabilir.\n\nBu bağlantıyı kullanarak istediğin zaman bağış geçmişini görüntüleyebilir, faturaları indirebilir, {interval, select,\n month {ve aboneliğini yönetebilir veya iptal edebilirsin}\n year {ve aboneliğini yönetebilir veya iptal edebilirsin}\n other {ve gelecekteki bağışlarını yönetebilirsin}\n}:\n\n{manageUrl}\n\nDesteğin {product_name}'ın çalışmaya devam etmesine yardımcı oluyor. Teşekkürler!\n\n– {product_name} Ekibi"
},
"donation_magic_link": {
"subject": "{product_name} bağışlarını yönet",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Дякуємо за твою пожертву для {product_name}",
"body": "Привіт,\n\nДякуємо за твою пожертву для {product_name}! Твою {interval, select,\n month {регулярну пожертву}\n year {регулярну пожертву}\n other {одноразову пожертву}\n} успішно {interval, select,\n month {налаштовано}\n year {налаштовано}\n other {оброблено}\n}.\n\nДеталі пожертви:\nСума: {amount} {currency} {interval, select,\n month {на місяць}\n year {на рік}\n other {}\n}\n\nStripe незабаром надішле тобі окрему квитанцію з рахунком у форматі PDF. Вона містить усі платіжні дані, і її можна використати для податкових цілей.\n\nТи можеш переглядати історію своїх пожертв, завантажувати рахунки, {interval, select,\n month {а також керувати підпискою або скасувати її}\n year {а також керувати підпискою або скасувати її}\n other {а також керувати майбутніми пожертвами}\n} у будь-який час за цим посиланням:\n\n{manageUrl}\n\nТвоя підтримка допомагає {product_name} працювати. Дякуємо!\n\n– Команда {product_name}"
"body": "Привіт,\n\nДякуємо за твою пожертву для {product_name}! Твою {interval, select,\n month {регулярну пожертву}\n year {регулярну пожертву}\n other {одноразову пожертву}\n} успішно {interval, select,\n month {налаштовано}\n year {налаштовано}\n other {оброблено}\n}.\n\nДеталі пожертви:\nСума: {amount} {interval, select,\n month {на місяць}\n year {на рік}\n other {}\n}\n\nStripe незабаром надішле тобі окрему квитанцію з рахунком у форматі PDF. Вона містить усі платіжні дані, і її можна використати для податкових цілей.\n\nТи можеш переглядати історію своїх пожертв, завантажувати рахунки, {interval, select,\n month {а також керувати підпискою або скасувати її}\n year {а також керувати підпискою або скасувати її}\n other {а також керувати майбутніми пожертвами}\n} у будь-який час за цим посиланням:\n\n{manageUrl}\n\nТвоя підтримка допомагає {product_name} працювати. Дякуємо!\n\n– Команда {product_name}"
},
"donation_magic_link": {
"subject": "Керуй своїми пожертвами для {product_name}",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Cảm ơn bạn đã ủng hộ {product_name}",
"body": "Xin chào,\n\nCảm ơn bạn đã ủng hộ {product_name}! {interval, select,\n month {Khoản ủng hộ định kỳ}\n year {Khoản ủng hộ định kỳ}\n other {Khoản ủng hộ một lần}\n} của bạn đã được {interval, select,\n month {thiết lập}\n year {thiết lập}\n other {xử lý}\n} thành công.\n\nChi tiết ủng hộ:\nSố tiền: {amount} {currency} {interval, select,\n month {mỗi tháng}\n year {mỗi năm}\n other {}\n}\n\nStripe sẽ sớm gửi cho bạn một email biên lai riêng kèm hóa đơn PDF. Biên lai này gồm đầy đủ chi tiết thanh toán và có thể dùng cho mục đích thuế.\n\nBạn có thể xem lịch sử ủng hộ, tải xuống hóa đơn, {interval, select,\n month {và quản lý hoặc hủy gói đăng ký}\n year {và quản lý hoặc hủy gói đăng ký}\n other {và quản lý các khoản ủng hộ trong tương lai}\n} bất cứ lúc nào bằng cách sử dụng liên kết này:\n\n{manageUrl}\n\nSự hỗ trợ của bạn giúp {product_name} tiếp tục hoạt động. Cảm ơn bạn!\n\n– Đội ngũ {product_name}"
"body": "Xin chào,\n\nCảm ơn bạn đã ủng hộ {product_name}! {interval, select,\n month {Khoản ủng hộ định kỳ}\n year {Khoản ủng hộ định kỳ}\n other {Khoản ủng hộ một lần}\n} của bạn đã được {interval, select,\n month {thiết lập}\n year {thiết lập}\n other {xử lý}\n} thành công.\n\nChi tiết ủng hộ:\nSố tiền: {amount} {interval, select,\n month {mỗi tháng}\n year {mỗi năm}\n other {}\n}\n\nStripe sẽ sớm gửi cho bạn một email biên lai riêng kèm hóa đơn PDF. Biên lai này gồm đầy đủ chi tiết thanh toán và có thể dùng cho mục đích thuế.\n\nBạn có thể xem lịch sử ủng hộ, tải xuống hóa đơn, {interval, select,\n month {và quản lý hoặc hủy gói đăng ký}\n year {và quản lý hoặc hủy gói đăng ký}\n other {và quản lý các khoản ủng hộ trong tương lai}\n} bất cứ lúc nào bằng cách sử dụng liên kết này:\n\n{manageUrl}\n\nSự hỗ trợ của bạn giúp {product_name} tiếp tục hoạt động. Cảm ơn bạn!\n\n– Đội ngũ {product_name}"
},
"donation_magic_link": {
"subject": "Quản lý các khoản ủng hộ {product_name} của bạn",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "感谢你的 {product_name} 捐赠",
"body": "你好,\n\n感谢你对 {product_name} 的捐赠!你的{interval, select,\n month {定期捐赠}\n year {定期捐赠}\n other {一次性捐赠}\n}{interval, select,\n month {已成功设置}\n year {已成功设置}\n other {已成功处理}\n}。\n\n捐赠详情:\n金额:{amount} {currency} {interval, select,\n month {每月}\n year {每年}\n other {}\n}\n\nStripe 稍后会另外发送一封收据邮件,其中附有发票 PDF。邮件包含所有付款详情,可用于报税。\n\n你可以随时使用此链接查看你的捐赠历史、下载发票,以及{interval, select,\n month {管理或取消你的订阅}\n year {管理或取消你的订阅}\n other {管理你的未来捐赠}\n}:\n\n{manageUrl}\n\n你的支持有助于 {product_name} 的持续运行。谢谢!\n\n– {product_name} 团队"
"body": "你好,\n\n感谢你对 {product_name} 的捐赠!你的{interval, select,\n month {定期捐赠}\n year {定期捐赠}\n other {一次性捐赠}\n}{interval, select,\n month {已成功设置}\n year {已成功设置}\n other {已成功处理}\n}。\n\n捐赠详情:\n金额:{amount} {interval, select,\n month {每月}\n year {每年}\n other {}\n}\n\nStripe 稍后会另外发送一封收据邮件,其中附有发票 PDF。邮件包含所有付款详情,可用于报税。\n\n你可以随时使用此链接查看你的捐赠历史、下载发票,以及{interval, select,\n month {管理或取消你的订阅}\n year {管理或取消你的订阅}\n other {管理你的未来捐赠}\n}:\n\n{manageUrl}\n\n你的支持有助于 {product_name} 的持续运行。谢谢!\n\n– {product_name} 团队"
},
"donation_magic_link": {
"subject": "管理你的 {product_name} 捐赠",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "感謝您對 {product_name} 的捐款",
"body": "哈囉,\n\n感謝您對 {product_name} 的捐款!您的{interval, select,\n month {定期捐款}\n year {定期捐款}\n other {一次性捐款}\n}已成功{interval, select,\n month {設定}\n year {設定}\n other {處理}\n}。\n\n捐款詳情:\n金額:{amount} {currency} {interval, select,\n month {每月}\n year {每年}\n other {}\n}\n\nStripe 稍後會另外寄給您一封收據,其中附有發票 PDF。這份收據包含所有付款詳情,可用於報稅。\n\n您可以隨時使用此連結查看您的捐款紀錄、下載發票,{interval, select,\n month {以及管理或取消您的訂閱}\n year {以及管理或取消您的訂閱}\n other {以及管理未來的捐款}\n}:\n\n{manageUrl}\n\n您的支持讓 {product_name} 得以持續運作。謝謝您!\n\n– {product_name} 團隊"
"body": "哈囉,\n\n感謝您對 {product_name} 的捐款!您的{interval, select,\n month {定期捐款}\n year {定期捐款}\n other {一次性捐款}\n}已成功{interval, select,\n month {設定}\n year {設定}\n other {處理}\n}。\n\n捐款詳情:\n金額:{amount} {interval, select,\n month {每月}\n year {每年}\n other {}\n}\n\nStripe 稍後會另外寄給您一封收據,其中附有發票 PDF。這份收據包含所有付款詳情,可用於報稅。\n\n您可以隨時使用此連結查看您的捐款紀錄、下載發票,{interval, select,\n month {以及管理或取消您的訂閱}\n year {以及管理或取消您的訂閱}\n other {以及管理未來的捐款}\n}:\n\n{manageUrl}\n\n您的支持讓 {product_name} 得以持續運作。謝謝您!\n\n– {product_name} 團隊"
},
"donation_magic_link": {
"subject": "管理您的 {product_name} 捐款",
@@ -13,7 +13,7 @@
},
"donation_confirmation": {
"subject": "Thank you for your {product_name} donation",
"body": "Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {currency} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team"
"body": "Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team"
},
"donation_magic_link": {
"subject": "Manage your {product_name} donations",
+10
View File
@@ -147,6 +147,10 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
if (Buffer.from(uploadRelaySecretBase64, 'base64').length < 32) {
throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must decode to at least 32 bytes');
}
const donationProxyKey = (master.services.api.donation_proxy_key ?? '').trim();
if (donationProxyKey.length > 0 && donationProxyKey.length < 32) {
throw new Error('FLUXER_API_DONATION_PROXY_KEY must be at least 32 characters');
}
if (!s3Config) {
throw new Error('S3 configuration is required for the API');
}
@@ -233,6 +237,11 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
jetStreamUrl: master.services.nats?.jetstream_url ?? 'nats://127.0.0.1:4223',
authToken: master.services.nats?.auth_token ?? '',
},
storageChangeFeed: {
enabled: master.services.api.storage_change_feed?.enabled ?? false,
stream: master.services.api.storage_change_feed?.stream ?? 'STORAGE_CHANGES',
skipBuckets: master.services.api.storage_change_feed?.skip_buckets ?? [s3Buckets.uploads],
},
search: {
engine: master.integrations.search?.engine ?? 'elasticsearch',
url: master.integrations.search?.url ?? 'http://127.0.0.1:9200',
@@ -275,6 +284,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
internal: {
gateway: resolveGatewayInternalUrl(master),
gatewayRpcAuthToken: master.services.gateway.rpc_auth_token ?? '',
donationProxyKey,
},
hosts: {
invite: extractHostname(master.endpoints.invite),
+2
View File
@@ -7,6 +7,7 @@ import {hasDatabaseQueryExecutor, setDatabaseQueryExecutor} from '@app/api/datab
import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from '@app/api/database/PostgresKvQueryExecutor';
import {GuildDataRepository} from '@app/api/guild/repositories/GuildDataRepository';
import type {ILogger} from '@app/api/ILogger';
import {shutdownStorageChangeFeed} from '@app/api/infrastructure/StorageServiceFactory';
import {JobLedgerRepository} from '@app/api/jobs/JobLedgerRepository';
import {startAbuseReplicationSubscriber, stopAbuseReplicationSubscriber} from '@app/api/middleware/AbusiveIpAutoBanner';
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
@@ -258,6 +259,7 @@ export function createShutdown(config: APIConfig, logger: ILogger): () => Promis
logger.error({error}, 'Error draining JetStream worker connection');
}
}
await shutdownStorageChangeFeed();
setInjectedWorkerService(undefined);
try {
await shutdownSearch();
@@ -156,6 +156,7 @@ export function ChannelController(app: HonoApp) {
const data = ctx.req.valid('json');
const clientFeatures = parseClientFeaturesHeader(ctx.req.header(CLIENT_FEATURES_HEADER));
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
const channelRequestService = ctx.get('channelRequestService');
return ctx.json(
await channelRequestService.updateChannel({
@@ -164,6 +165,7 @@ export function ChannelController(app: HonoApp) {
data,
clientFeatures,
requestCache,
auditLogReason,
}),
);
},
@@ -195,6 +197,7 @@ export function ChannelController(app: HonoApp) {
const {silent, delete_messages} = ctx.req.valid('query');
const body = ctx.req.valid('json');
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
const channelRequestService = ctx.get('channelRequestService');
await ctx.get('channelService').channelData.operations.getChannel({userId, channelId});
if (delete_messages) {
@@ -203,7 +206,7 @@ export function ChannelController(app: HonoApp) {
channelIds: [channelId],
});
}
await channelRequestService.deleteChannel({userId, channelId, requestCache, silent});
await channelRequestService.deleteChannel({userId, channelId, requestCache, silent, auditLogReason});
return ctx.body(null, 204);
},
);
@@ -300,6 +303,7 @@ export function ChannelController(app: HonoApp) {
const data = ctx.req.valid('json');
const clientFeatures = parseClientFeaturesHeader(ctx.req.header(CLIENT_FEATURES_HEADER));
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('channelService').channelData.operations.setChannelPermissionOverwrite({
userId,
channelId,
@@ -311,6 +315,7 @@ export function ChannelController(app: HonoApp) {
},
clientFeatures,
requestCache,
auditLogReason,
});
return ctx.body(null, 204);
},
@@ -335,9 +340,14 @@ export function ChannelController(app: HonoApp) {
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const overwriteId = ctx.req.valid('param').overwrite_id;
const requestCache = ctx.get('requestCache');
await ctx
.get('channelService')
.channelData.operations.deleteChannelPermissionOverwrite({userId, channelId, overwriteId, requestCache});
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('channelService').channelData.operations.deleteChannelPermissionOverwrite({
userId,
channelId,
overwriteId,
requestCache,
auditLogReason,
});
return ctx.body(null, 204);
},
);
@@ -364,7 +364,10 @@ export function MessageController(app: HonoApp) {
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const requestCache = ctx.get('requestCache');
await ctx.get('channelService').messages.deletion.deleteMessage({userId, channelId, messageId, requestCache});
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx
.get('channelService')
.messages.deletion.deleteMessage({userId, channelId, messageId, requestCache, auditLogReason});
return ctx.body(null, 204);
},
);
@@ -420,7 +423,10 @@ export function MessageController(app: HonoApp) {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const messageIds = ctx.req.valid('json').message_ids.map(createMessageID);
await ctx.get('channelService').messages.deletion.bulkDeleteMessages({userId, channelId, messageIds});
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx
.get('channelService')
.messages.deletion.bulkDeleteMessages({userId, channelId, messageIds, auditLogReason});
return ctx.body(null, 204);
},
);
@@ -106,11 +106,13 @@ export function MessageInteractionController(app: HonoApp) {
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('channelService').interactions.pinMessage({
userId,
channelId,
messageId,
requestCache,
auditLogReason,
});
return ctx.body(null, 204);
},
@@ -136,11 +138,13 @@ export function MessageInteractionController(app: HonoApp) {
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('channelService').interactions.unpinMessage({
userId,
channelId,
messageId,
requestCache,
auditLogReason,
});
return ctx.body(null, 204);
},
@@ -104,12 +104,14 @@ export class ChannelDataService {
data,
clientFeatures,
requestCache,
auditLogReason,
}: {
userId: UserID;
channelId: ChannelID;
data: Omit<ChannelUpdateRequest, 'type'>;
clientFeatures: ReadonlySet<string>;
requestCache: RequestCache;
auditLogReason: string | null;
}): Promise<Channel> {
const {channel} = await this.auth.getChannelAuthenticated({userId, channelId, skipNsfwValidation: true});
if (channel.type === ChannelTypes.GROUP_DM) {
@@ -182,6 +184,7 @@ export class ChannelDataService {
data: channelUpdateData,
clientFeatures,
requestCache,
auditLogReason,
});
}
}
@@ -61,6 +61,7 @@ export class ChannelRequestService {
data: ChannelUpdateRequest;
clientFeatures: ReadonlySet<string>;
requestCache: RequestCache;
auditLogReason: string | null;
}): Promise<ChannelResponse> {
const channel = await this.channelService.channelData.editChannel({
userId: params.userId,
@@ -68,6 +69,7 @@ export class ChannelRequestService {
data: params.data,
clientFeatures: params.clientFeatures,
requestCache: params.requestCache,
auditLogReason: params.auditLogReason,
});
return mapChannelToResponse({
channel,
@@ -82,6 +84,7 @@ export class ChannelRequestService {
channelId: ChannelID;
requestCache: RequestCache;
silent?: boolean;
auditLogReason: string | null;
}): Promise<void> {
const channel = await this.channelService.channelData.operations.getChannel({
userId: params.userId,
@@ -101,6 +104,7 @@ export class ChannelRequestService {
userId: params.userId,
channelId: params.channelId,
requestCache: params.requestCache,
auditLogReason: params.auditLogReason,
});
}
}
@@ -97,17 +97,19 @@ export class MessageInteractionService {
channelId,
messageId,
requestCache,
auditLogReason,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
requestCache: RequestCache;
auditLogReason?: string | null;
}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
if (!authChannel.guild && authChannel.channel.type !== ChannelTypes.DM_PERSONAL_NOTES) {
await this.authService.validateDMSendPermissions({channel: authChannel.channel, userId});
}
await this.pinService.pinMessage({authChannel, messageId, userId, requestCache});
await this.pinService.pinMessage({authChannel, messageId, userId, requestCache, auditLogReason});
}
async unpinMessage({
@@ -115,17 +117,19 @@ export class MessageInteractionService {
channelId,
messageId,
requestCache,
auditLogReason,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
requestCache: RequestCache;
auditLogReason?: string | null;
}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
if (!authChannel.guild && authChannel.channel.type !== ChannelTypes.DM_PERSONAL_NOTES) {
await this.authService.validateDMSendPermissions({channel: authChannel.channel, userId});
}
await this.pinService.unpinMessage({authChannel, messageId, userId, requestCache});
await this.pinService.unpinMessage({authChannel, messageId, userId, requestCache, auditLogReason});
}
async getUsersForReaction({
@@ -129,12 +129,14 @@ export class ChannelOperationsService {
data,
clientFeatures,
requestCache,
auditLogReason,
}: {
userId: UserID;
channelId: ChannelID;
data: ChannelUpdateData;
clientFeatures: ReadonlySet<string>;
requestCache: RequestCache;
auditLogReason: string | null;
}): Promise<Channel> {
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
userId,
@@ -339,7 +341,7 @@ export class ChannelOperationsService {
const builder = this.guildAuditLogService
.createBuilder(guildIdValue, userId)
.withAction(AuditLogActionType.CHANNEL_UPDATE, channel.id.toString())
.withReason(null)
.withReason(auditLogReason)
.withMetadata({
type: updatedChannel.type.toString(),
})
@@ -358,8 +360,6 @@ export class ChannelOperationsService {
'Failed to record guild audit log',
);
}
if (data.name !== undefined && channel.name !== updatedChannel.name) {
}
}
if (data.permission_overwrites !== undefined) {
await this.guildAuditLogService.recordPermissionOverwriteDiff({
@@ -368,6 +368,7 @@ export class ChannelOperationsService {
channelId: updatedChannel.id,
previous: previousPermissionOverwrites,
next: updatedChannel.permissionOverwrites,
reason: auditLogReason,
});
}
return updatedChannel;
@@ -377,10 +378,12 @@ export class ChannelOperationsService {
userId,
channelId,
requestCache,
auditLogReason,
}: {
userId: UserID;
channelId: ChannelID;
requestCache: RequestCache;
auditLogReason: string | null;
}): Promise<void> {
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
userId,
@@ -421,7 +424,7 @@ export class ChannelOperationsService {
const builder = this.guildAuditLogService
.createBuilder(guildIdValue, userId)
.withAction(AuditLogActionType.CHANNEL_DELETE, channel.id.toString())
.withReason(null)
.withReason(auditLogReason)
.withMetadata({
type: channel.type.toString(),
})
@@ -611,6 +614,7 @@ export class ChannelOperationsService {
};
clientFeatures: ReadonlySet<string>;
requestCache: RequestCache;
auditLogReason: string | null;
}): Promise<void> {
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
if (!channel || !channel.guildId) throw new UnknownChannelError();
@@ -646,15 +650,13 @@ export class ChannelOperationsService {
const removedDeny = (existing?.deny ?? 0n) & ~sanitizedDeny;
if (!hasAdministrator && (removedDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
const previousPermissionOverwrites = channel.permissionOverwrites;
const nextOverwrite = new ChannelPermissionOverwrite({
type: params.overwrite.type,
allow_: sanitizedAllow,
deny_: sanitizedDeny,
});
const overwrites = new Map(channel.permissionOverwrites ?? []);
overwrites.set(
targetId,
new ChannelPermissionOverwrite({
type: params.overwrite.type,
allow_: sanitizedAllow,
deny_: sanitizedDeny,
}),
);
overwrites.set(targetId, nextOverwrite);
const updated = await this.channelRepository.channelData.upsert({
...channel.toRow(),
permission_overwrites: new Map(
@@ -670,50 +672,14 @@ export class ChannelOperationsService {
requestCache: params.requestCache,
});
}
const previousSnapshot = existing
? {
id: params.overwriteId.toString(),
type: existing.type.toString(),
allow: existing.allow.toString(),
deny: existing.deny.toString(),
}
: null;
const nextSnapshot = {
id: params.overwriteId.toString(),
type: params.overwrite.type.toString(),
allow: sanitizedAllow.toString(),
deny: sanitizedDeny.toString(),
};
const changes = this.guildAuditLogService.computeChanges(previousSnapshot, nextSnapshot);
if (changes.length > 0) {
const action = existing
? AuditLogActionType.CHANNEL_OVERWRITE_UPDATE
: AuditLogActionType.CHANNEL_OVERWRITE_CREATE;
const builder = this.guildAuditLogService
.createBuilder(channel.guildId, params.userId)
.withAction(action, params.overwriteId.toString())
.withReason(null)
.withMetadata({
id: params.overwriteId.toString(),
type: params.overwrite.type.toString(),
channel_id: channel.id.toString(),
})
.withChanges(changes);
try {
await builder.commit();
} catch (error) {
Logger.error(
{
error,
guildId: channel.guildId.toString(),
userId: params.userId.toString(),
action,
targetId: params.overwriteId.toString(),
},
'Failed to record guild audit log',
);
}
}
await this.guildAuditLogService.recordPermissionOverwriteDiff({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
previous: existing ? new Map([[targetId, existing]]) : null,
next: new Map([[targetId, nextOverwrite]]),
reason: params.auditLogReason,
});
}
async deleteChannelPermissionOverwrite(params: {
@@ -721,6 +687,7 @@ export class ChannelOperationsService {
channelId: ChannelID;
overwriteId: bigint;
requestCache: RequestCache;
auditLogReason: string | null;
}): Promise<void> {
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
if (!channel || !channel.guildId) throw new UnknownChannelError();
@@ -763,37 +730,15 @@ export class ChannelOperationsService {
});
}
if (removed) {
const previousSnapshot = {
id: params.overwriteId.toString(),
type: removed.type.toString(),
allow: removed.allow.toString(),
deny: removed.deny.toString(),
};
const changes = this.guildAuditLogService.computeChanges(previousSnapshot, null);
const builder = this.guildAuditLogService
.createBuilder(channel.guildId, params.userId)
.withAction(AuditLogActionType.CHANNEL_OVERWRITE_DELETE, params.overwriteId.toString())
.withReason(null)
.withMetadata({
id: params.overwriteId.toString(),
type: removed.type.toString(),
channel_id: channel.id.toString(),
})
.withChanges(changes);
try {
await builder.commit();
} catch (error) {
Logger.error(
{
error,
guildId: channel.guildId.toString(),
userId: params.userId.toString(),
action: AuditLogActionType.CHANNEL_OVERWRITE_DELETE,
targetId: params.overwriteId.toString(),
},
'Failed to record guild audit log',
);
}
const removedTargetId = removed.type === 0 ? createRoleID(params.overwriteId) : createUserID(params.overwriteId);
await this.guildAuditLogService.recordPermissionOverwriteDiff({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
previous: new Map([[removedTargetId, removed]]),
next: null,
reason: params.auditLogReason,
});
}
}
}
@@ -142,11 +142,13 @@ export class MessagePinService extends MessageInteractionBase {
authChannel,
messageId,
userId,
auditLogReason,
}: {
authChannel: AuthenticatedChannel;
messageId: MessageID;
userId: UserID;
requestCache: RequestCache;
auditLogReason?: string | null;
}): Promise<void> {
const {channel, guild, checkPermission} = authChannel;
if (guild) {
@@ -182,7 +184,7 @@ export class MessagePinService extends MessageInteractionBase {
channel_id: channel.id.toString(),
message_id: messageId.toString(),
})
.withReason(null)
.withReason(auditLogReason ?? null)
.commit();
}
}
@@ -191,11 +193,13 @@ export class MessagePinService extends MessageInteractionBase {
authChannel,
messageId,
userId,
auditLogReason,
}: {
authChannel: AuthenticatedChannel;
messageId: MessageID;
userId: UserID;
requestCache: RequestCache;
auditLogReason?: string | null;
}): Promise<void> {
const {channel, guild, checkPermission} = authChannel;
if (guild) {
@@ -227,7 +231,7 @@ export class MessagePinService extends MessageInteractionBase {
channel_id: channel.id.toString(),
message_id: messageId.toString(),
})
.withReason(null)
.withReason(auditLogReason ?? null)
.commit();
}
}
@@ -53,12 +53,14 @@ export class MessageDeleteService {
channelId,
messageId,
skipGuildAuditLog,
auditLogReason,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
requestCache: RequestCache;
skipGuildAuditLog?: boolean;
auditLogReason?: string | null;
}): Promise<void> {
const {channel, guild, hasPermission} = await this.deps.channelAuthService.getChannelAuthenticated({
userId,
@@ -97,7 +99,7 @@ export class MessageDeleteService {
.createBuilder(channel.guildId, userId)
.withAction(AuditLogActionType.MESSAGE_DELETE, message.id.toString())
.withMetadata({channel_id: channel.id.toString()})
.withReason(null)
.withReason(auditLogReason ?? null)
.commit();
}
await this.deps.searchService.deleteMessageIndex(messageId);
@@ -149,10 +151,12 @@ export class MessageDeleteService {
userId,
channelId,
messageIds,
auditLogReason,
}: {
userId: UserID;
channelId: ChannelID;
messageIds: Array<MessageID>;
auditLogReason?: string | null;
}): Promise<void> {
if (messageIds.length === 0) {
throw InputValidationError.fromCode('message_ids', ValidationErrorCodes.MESSAGE_IDS_CANNOT_BE_EMPTY);
@@ -186,7 +190,7 @@ export class MessageDeleteService {
channel_id: channel.id.toString(),
count: existingMessages.length.toString(),
})
.withReason(null)
.withReason(auditLogReason ?? null)
.commit();
}
await this.deps.searchService.deleteMessagesIndex(messageIds);
+6
View File
@@ -94,6 +94,11 @@ export interface APIConfig {
jetStreamUrl: string;
authToken: string;
};
storageChangeFeed: {
enabled: boolean;
stream: string;
skipBuckets: Array<string>;
};
search: {
engine: 'elasticsearch' | 'meilisearch';
url: string;
@@ -134,6 +139,7 @@ export interface APIConfig {
internal: {
gateway: string;
gatewayRpcAuthToken: string;
donationProxyKey: string;
};
hosts: {
invite: string;
@@ -14,6 +14,7 @@ export interface DonorRow {
subscription_interval: Nullish<string>;
subscription_current_period_end: Nullish<Date>;
subscription_cancel_at: Nullish<Date>;
subscription_status: Nullish<string>;
created_at: Date;
updated_at: Date;
version: number;
@@ -53,6 +54,7 @@ export const DONOR_COLUMNS = [
'subscription_interval',
'subscription_current_period_end',
'subscription_cancel_at',
'subscription_status',
'created_at',
'updated_at',
'version',
@@ -6,6 +6,11 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {DonationRateLimitConfigs} from '@app/api/rate_limit_configs/DonationRateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {DonationMagicLinkExpiredError} from '@fluxer/errors/src/domains/donation/DonationMagicLinkExpiredError';
import {DonationMagicLinkInvalidError} from '@fluxer/errors/src/domains/donation/DonationMagicLinkInvalidError';
import {DonationMagicLinkUsedError} from '@fluxer/errors/src/domains/donation/DonationMagicLinkUsedError';
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
import {StripePaymentNotAvailableError} from '@fluxer/errors/src/domains/payment/StripePaymentNotAvailableError';
import {
DonationCheckoutRequest,
DonationCheckoutResponse,
@@ -13,6 +18,26 @@ import {
DonationRequestLinkRequest,
} from '@fluxer/schema/src/domains/donation/DonationSchemas';
function donationManageAlertUrl(alert: string): string {
return `${Config.endpoints.marketing}/donate/manage?alert=${alert}`;
}
function getMagicLinkAlert(error: unknown): string | null {
if (error instanceof DonationMagicLinkExpiredError) {
return 'link_expired';
}
if (error instanceof DonationMagicLinkUsedError) {
return 'link_used';
}
if (error instanceof DonationMagicLinkInvalidError) {
return 'link_invalid';
}
if (error instanceof StripeError || error instanceof StripePaymentNotAvailableError) {
return 'portal_error';
}
return null;
}
export function DonationController(app: HonoApp) {
app.post(
'/donations/request-link',
@@ -38,8 +63,9 @@ export function DonationController(app: HonoApp) {
RateLimitMiddleware(DonationRateLimitConfigs.DONATION_MANAGE),
OpenAPI({
operationId: 'manage_donation',
summary: 'Manage donation subscription',
description: 'Validates the magic link token and redirects to Stripe billing portal.',
summary: 'Open donation management link',
description:
'Checks the magic link token without consuming it and redirects to the donation management confirmation page.',
responseSchema: null,
statusCode: 302,
security: [],
@@ -48,12 +74,50 @@ export function DonationController(app: HonoApp) {
Validator('query', DonationManageQuery),
async (ctx) => {
const {token} = ctx.req.valid('query');
const {stripeCustomerId} = await ctx.get('donationService').validateMagicLinkToken(token);
if (!stripeCustomerId) {
return ctx.redirect(`${Config.endpoints.marketing}/donate`);
try {
const {stripeCustomerId} = await ctx.get('donationService').validateMagicLinkToken(token);
if (!stripeCustomerId) {
return ctx.redirect(donationManageAlertUrl('no_customer'), 302);
}
} catch (error: unknown) {
const alert = getMagicLinkAlert(error);
if (!alert) {
throw error;
}
return ctx.redirect(donationManageAlertUrl(alert), 302);
}
const encodedToken = encodeURIComponent(token);
return ctx.redirect(`${Config.endpoints.marketing}/donate/manage/confirm?token=${encodedToken}`, 302);
},
);
app.post(
'/donations/manage',
RateLimitMiddleware(DonationRateLimitConfigs.DONATION_MANAGE),
OpenAPI({
operationId: 'redeem_donation_magic_link',
summary: 'Redeem donation management link',
description: 'Consumes the magic link token and redirects to the Stripe billing portal.',
responseSchema: null,
statusCode: 302,
security: [],
tags: 'Donations',
}),
Validator('query', DonationManageQuery),
async (ctx) => {
const {token} = ctx.req.valid('query');
try {
const portalUrl = await ctx.get('donationService').redeemMagicLinkToken(token);
if (!portalUrl) {
return ctx.redirect(donationManageAlertUrl('no_customer'), 302);
}
return ctx.redirect(portalUrl, 302);
} catch (error: unknown) {
const alert = getMagicLinkAlert(error);
if (!alert) {
throw error;
}
return ctx.redirect(donationManageAlertUrl(alert), 302);
}
const portalUrl = await ctx.get('donationService').createDonorPortalSession(stripeCustomerId);
return ctx.redirect(portalUrl);
},
);
app.post(
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {BatchBuilder, deleteOneOrMany, fetchMany, fetchOne} from '@app/api/database/CassandraQueryExecution';
import {BatchBuilder, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {Db, type PatchObject} from '@app/api/database/CassandraTypes';
import {executeVersionedUpdate} from '@app/api/database/CassandraVersionedUpdate';
import type {
@@ -101,6 +101,7 @@ export class DonationRepository extends IDonationRepository {
subscriptionInterval: string | null;
subscriptionCurrentPeriodEnd: Date | null;
subscriptionCancelAt?: Date | null;
subscriptionStatus?: string | null;
}): Promise<Donor> {
const now = new Date();
const donorRow: DonorRow = {
@@ -115,6 +116,7 @@ export class DonationRepository extends IDonationRepository {
subscription_interval: data.subscriptionInterval,
subscription_current_period_end: data.subscriptionCurrentPeriodEnd,
subscription_cancel_at: data.subscriptionCancelAt ?? null,
subscription_status: data.subscriptionStatus ?? null,
created_at: now,
updated_at: now,
version: 1,
@@ -154,6 +156,7 @@ export class DonationRepository extends IDonationRepository {
subscriptionInterval: string | null;
subscriptionCurrentPeriodEnd: Date | null;
subscriptionCancelAt?: Date | null;
subscriptionStatus?: string | null;
},
): Promise<Donor | null> {
await executeVersionedUpdate(
@@ -167,6 +170,7 @@ export class DonationRepository extends IDonationRepository {
subscription_interval: Db.set(data.subscriptionInterval),
subscription_current_period_end: Db.set(data.subscriptionCurrentPeriodEnd),
subscription_cancel_at: Db.set(data.subscriptionCancelAt ?? null),
subscription_status: Db.set(data.subscriptionStatus ?? null),
updated_at: Db.set(new Date()),
};
if (data.businessName !== undefined) {
@@ -206,6 +210,84 @@ export class DonationRepository extends IDonationRepository {
return this.findDonorByEmail(email);
}
async updateDonorCustomerDetails(
email: string,
data: {
stripeCustomerId: string | null;
businessName?: string | null;
taxId?: string | null;
taxIdType?: string | null;
},
): Promise<Donor | null> {
await executeVersionedUpdate(
() => fetchOne<DonorRow>(FETCH_DONOR_BY_EMAIL_QUERY, {email}),
() => {
const patch: PatchObject = {
stripe_customer_id: Db.set(data.stripeCustomerId),
updated_at: Db.set(new Date()),
};
if (data.businessName !== undefined) {
patch.business_name = Db.set(data.businessName);
}
if (data.taxId !== undefined) {
patch.tax_id = Db.set(data.taxId);
}
if (data.taxIdType !== undefined) {
patch.tax_id_type = Db.set(data.taxIdType);
}
return {
pk: {email},
patch,
};
},
Donors,
);
if (data.stripeCustomerId) {
await upsertOne(
DonorsByStripeCustomerId.upsertAll({
stripe_customer_id: data.stripeCustomerId,
email,
}),
);
}
return this.findDonorByEmail(email);
}
async linkDonorStripeCustomer(email: string, stripeCustomerId: string): Promise<void> {
await upsertOne(
DonorsByStripeCustomerId.upsertAll({
stripe_customer_id: stripeCustomerId,
email,
}),
);
}
async clearDonorStripeCustomer(stripeCustomerId: string): Promise<void> {
const donor = await this.findDonorByStripeCustomerId(stripeCustomerId);
if (!donor) {
return;
}
if (donor.stripeCustomerId === stripeCustomerId) {
await executeVersionedUpdate(
() => fetchOne<DonorRow>(FETCH_DONOR_BY_EMAIL_QUERY, {email: donor.email}),
() => ({
pk: {email: donor.email},
patch: {
stripe_customer_id: Db.clear(),
updated_at: Db.set(new Date()),
},
}),
Donors,
);
}
await deleteOneOrMany(
DonorsByStripeCustomerId.deleteByPk({
stripe_customer_id: stripeCustomerId,
email: donor.email,
}),
);
}
async cancelDonorSubscription(email: string): Promise<void> {
const current = await this.findDonorByEmail(email);
if (!current) {
@@ -223,6 +305,7 @@ export class DonationRepository extends IDonationRepository {
subscription_interval: Db.clear(),
subscription_current_period_end: Db.clear(),
subscription_cancel_at: Db.clear(),
subscription_status: Db.clear(),
updated_at: Db.set(new Date()),
},
}),
@@ -238,6 +321,15 @@ export class DonationRepository extends IDonationRepository {
}
}
async deleteDonorSubscriptionMapping(stripeSubscriptionId: string, email: string): Promise<void> {
await deleteOneOrMany(
DonorsByStripeSubscriptionId.deleteByPk({
stripe_subscription_id: stripeSubscriptionId,
email,
}),
);
}
async createMagicLinkToken(token: DonorMagicLinkToken): Promise<void> {
const row = token.toRow();
const batch = new BatchBuilder();
+13 -4
View File
@@ -22,6 +22,19 @@ export class DonationService implements IDonationService {
return this.magicLinkService.validateToken(token);
}
async redeemMagicLinkToken(token: string): Promise<string | null> {
const {stripeCustomerId} = await this.magicLinkService.validateToken(token);
if (!stripeCustomerId) {
return null;
}
const portalUrl = await this.checkoutService.createPortalSession(stripeCustomerId);
if (!portalUrl) {
return null;
}
await this.magicLinkService.consumeToken(token);
return portalUrl;
}
async createDonationCheckout(params: {
email: string;
amountCents: number;
@@ -32,8 +45,4 @@ export class DonationService implements IDonationService {
}): Promise<string> {
return this.checkoutService.createCheckout(params);
}
async createDonorPortalSession(stripeCustomerId: string): Promise<string> {
return this.checkoutService.createPortalSession(stripeCustomerId);
}
}
@@ -24,6 +24,7 @@ export abstract class IDonationRepository {
subscriptionInterval: string | null;
subscriptionCurrentPeriodEnd: Date | null;
subscriptionCancelAt?: Date | null;
subscriptionStatus?: string | null;
}): Promise<Donor>;
abstract updateDonorSubscription(
@@ -39,11 +40,28 @@ export abstract class IDonationRepository {
subscriptionInterval: string | null;
subscriptionCurrentPeriodEnd: Date | null;
subscriptionCancelAt?: Date | null;
subscriptionStatus?: string | null;
},
): Promise<Donor | null>;
abstract updateDonorCustomerDetails(
email: string,
data: {
stripeCustomerId: string | null;
businessName?: string | null;
taxId?: string | null;
taxIdType?: string | null;
},
): Promise<Donor | null>;
abstract linkDonorStripeCustomer(email: string, stripeCustomerId: string): Promise<void>;
abstract clearDonorStripeCustomer(stripeCustomerId: string): Promise<void>;
abstract cancelDonorSubscription(email: string): Promise<void>;
abstract deleteDonorSubscriptionMapping(stripeSubscriptionId: string, email: string): Promise<void>;
abstract createMagicLinkToken(token: DonorMagicLinkToken): Promise<void>;
abstract findMagicLinkToken(token: string): Promise<DonorMagicLinkToken | null>;
@@ -8,6 +8,7 @@ export interface IDonationService {
email: string;
stripeCustomerId: string | null;
}>;
redeemMagicLinkToken(token: string): Promise<string | null>;
createDonationCheckout(params: {
email: string;
amountCents: number;
@@ -16,5 +17,4 @@ export interface IDonationService {
isBusiness?: boolean;
locale?: string | null;
}): Promise<string>;
createDonorPortalSession(stripeCustomerId: string): Promise<string>;
}
@@ -3,6 +3,8 @@
import {nextVersion} from '@app/api/database/CassandraTypes';
import type {DonorRow} from '@app/api/database/types/DonationTypes';
const ACTIVE_SUBSCRIPTION_STATUSES: ReadonlySet<string> = new Set(['active', 'trialing']);
export class Donor {
readonly email: string;
readonly stripeCustomerId: string | null;
@@ -15,6 +17,7 @@ export class Donor {
readonly subscriptionInterval: string | null;
readonly subscriptionCurrentPeriodEnd: Date | null;
readonly subscriptionCancelAt: Date | null;
readonly subscriptionStatus: string | null;
readonly createdAt: Date;
readonly updatedAt: Date;
readonly version: number;
@@ -31,6 +34,7 @@ export class Donor {
this.subscriptionInterval = row.subscription_interval ?? null;
this.subscriptionCurrentPeriodEnd = row.subscription_current_period_end ?? null;
this.subscriptionCancelAt = row.subscription_cancel_at ?? null;
this.subscriptionStatus = row.subscription_status ?? null;
this.createdAt = row.created_at;
this.updatedAt = row.updated_at;
this.version = row.version;
@@ -49,6 +53,7 @@ export class Donor {
subscription_interval: this.subscriptionInterval,
subscription_current_period_end: this.subscriptionCurrentPeriodEnd,
subscription_cancel_at: this.subscriptionCancelAt,
subscription_status: this.subscriptionStatus,
created_at: this.createdAt,
updated_at: this.updatedAt,
version: this.version,
@@ -62,6 +67,9 @@ export class Donor {
if (this.subscriptionCancelAt !== null) {
return false;
}
if (this.subscriptionStatus !== null && !ACTIVE_SUBSCRIPTION_STATUSES.has(this.subscriptionStatus)) {
return false;
}
return this.subscriptionCurrentPeriodEnd > new Date();
}
@@ -80,6 +88,7 @@ export class Donor {
subscriptionInterval: string | null;
subscriptionCurrentPeriodEnd: Date | null;
subscriptionCancelAt?: Date | null;
subscriptionStatus?: string | null;
}): DonorRow {
return {
email: this.email,
@@ -93,6 +102,7 @@ export class Donor {
subscription_interval: data.subscriptionInterval,
subscription_current_period_end: data.subscriptionCurrentPeriodEnd,
subscription_cancel_at: data.subscriptionCancelAt ?? this.subscriptionCancelAt,
subscription_status: data.subscriptionStatus ?? this.subscriptionStatus,
created_at: this.createdAt,
updated_at: new Date(),
version: nextVersion(this.version),
@@ -18,9 +18,62 @@ import type Stripe from 'stripe';
type CheckoutSessionCreateParams = Stripe.Checkout.SessionCreateParams;
type CheckoutSessionMode = CheckoutSessionCreateParams['mode'];
type CheckoutSessionLineItem = NonNullable<CheckoutSessionCreateParams['line_items']>[number];
type CheckoutSessionLocale = NonNullable<CheckoutSessionCreateParams['locale']>;
const PRODUCT_NAME = 'Fluxer';
const STRIPE_CHECKOUT_LOCALES: Record<string, CheckoutSessionLocale> = {
bg: 'bg',
cs: 'cs',
da: 'da',
de: 'de',
el: 'el',
'en-GB': 'en-GB',
'en-US': 'en',
'es-419': 'es-419',
'es-ES': 'es',
fi: 'fi',
fr: 'fr',
hr: 'hr',
hu: 'hu',
id: 'id',
it: 'it',
ja: 'ja',
ko: 'ko',
lt: 'lt',
nl: 'nl',
no: 'nb',
pl: 'pl',
'pt-BR': 'pt-BR',
ro: 'ro',
ru: 'ru',
'sv-SE': 'sv',
th: 'th',
tr: 'tr',
vi: 'vi',
'zh-CN': 'zh',
'zh-TW': 'zh-TW',
};
function getStripeCheckoutLocale(locale: string | null): CheckoutSessionLocale {
if (!locale) {
return 'auto';
}
const checkoutLocale = STRIPE_CHECKOUT_LOCALES[locale];
return checkoutLocale ?? 'auto';
}
function isMissingCustomerError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'resource_missing' &&
'param' in error &&
error.param === 'customer'
);
}
function getDonationProductData(interval: 'month' | 'year' | null, locale: string | null) {
if (interval === 'month') {
return {
@@ -68,8 +121,7 @@ export class DonationCheckoutService {
const isRecurring = params.interval !== null;
const existingDonor = await this.donationRepository.findDonorByEmail(params.email);
if (isRecurring && existingDonor?.hasActiveSubscription()) {
const encodedEmail = encodeURIComponent(params.email);
return `${Config.endpoints.marketing}/donate/manage?email=${encodedEmail}&alert=active_subscription`;
return `${Config.endpoints.marketing}/donate/manage?email=${encodeURIComponent(params.email)}&alert=active_subscription`;
}
const locale = params.locale ?? null;
const donationProductData = getDonationProductData(params.interval, locale);
@@ -96,18 +148,24 @@ export class DonationCheckoutService {
quantity: 1,
};
const isBusiness = params.isBusiness === true;
const donationMetadata = {
is_donation: 'true',
donation_email: params.email,
};
const reusableCustomerId = isRecurring ? (existingDonor?.stripeCustomerId ?? null) : null;
const sessionParams: CheckoutSessionCreateParams = {
line_items: [lineItem],
mode,
locale: getStripeCheckoutLocale(locale),
...(reusableCustomerId ? {customer: reusableCustomerId} : {customer_email: params.email}),
metadata: {
is_donation: 'true',
donation_email: params.email,
...donationMetadata,
donation_type: isRecurring ? 'recurring' : 'one_time',
is_business: isBusiness ? 'true' : 'false',
...(locale ? {donation_locale: locale} : {}),
},
success_url: `${Config.endpoints.marketing}/donate/success`,
cancel_url: `${Config.endpoints.marketing}/donate`,
cancel_url: `${Config.endpoints.marketing}/donate?type=${isBusiness ? 'business' : 'individual'}&currency=${params.currency}`,
automatic_tax: {
enabled: false,
},
@@ -117,22 +175,32 @@ export class DonationCheckoutService {
...(isBusiness ? {billing_address_collection: 'required' as const} : {}),
...(mode === 'payment'
? {
customer_creation: 'always' as const,
invoice_creation: {
enabled: true,
},
payment_intent_data: {
metadata: donationMetadata,
},
}
: {}),
: {
subscription_data: {
metadata: donationMetadata,
},
}),
};
if (existingDonor?.stripeCustomerId) {
sessionParams.customer = existingDonor.stripeCustomerId;
sessionParams.customer_update = {
address: 'auto',
name: 'auto',
};
} else {
let session: Stripe.Checkout.Session;
try {
session = await this.stripe.checkout.sessions.create(sessionParams);
} catch (createError: unknown) {
if (!reusableCustomerId || !isMissingCustomerError(createError)) {
throw createError;
}
await this.clearStaleCustomerId(reusableCustomerId);
delete sessionParams.customer;
sessionParams.customer_email = params.email;
session = await this.stripe.checkout.sessions.create(sessionParams);
}
const session = await this.stripe.checkout.sessions.create(sessionParams);
try {
await getBillingRepository().checkoutSessions.upsertFromStripe(session);
} catch (mirrorErr) {
@@ -160,12 +228,11 @@ export class DonationCheckoutService {
throw error;
}
Logger.error({error, email: params.email}, 'Failed to create donation checkout session');
const message = error instanceof Error ? error.message : 'Failed to create checkout session';
throw new StripeError(message);
throw new StripeError('Failed to create checkout session');
}
}
async createPortalSession(stripeCustomerId: string): Promise<string> {
async createPortalSession(stripeCustomerId: string): Promise<string | null> {
if (!this.stripe) {
throw new StripePaymentNotAvailableError();
}
@@ -177,9 +244,17 @@ export class DonationCheckoutService {
Logger.debug({stripeCustomerId}, 'Donation portal session created');
return session.url;
} catch (error: unknown) {
if (isMissingCustomerError(error)) {
await this.clearStaleCustomerId(stripeCustomerId);
return null;
}
Logger.error({error, stripeCustomerId}, 'Failed to create donor portal session');
const message = error instanceof Error ? error.message : 'Failed to create portal session';
throw new StripeError(message);
throw new StripeError('Failed to create portal session');
}
}
private async clearStaleCustomerId(stripeCustomerId: string): Promise<void> {
await this.donationRepository.clearDonorStripeCustomer(stripeCustomerId);
Logger.warn({stripeCustomerId}, 'Cleared deleted Stripe customer from donor');
}
}
@@ -60,7 +60,6 @@ export class DonationMagicLinkService {
if (tokenModel.isUsed()) {
throw new DonationMagicLinkUsedError();
}
await this.donationRepository.markMagicLinkTokenUsed(token, new Date());
const donor = await this.donationRepository.findDonorByEmail(tokenModel.donorEmail);
Logger.debug({email: tokenModel.donorEmail}, 'Donation magic link validated');
return {
@@ -68,4 +67,8 @@ export class DonationMagicLinkService {
stripeCustomerId: donor?.stripeCustomerId ?? null,
};
}
async consumeToken(token: string): Promise<void> {
await this.donationRepository.markMagicLinkTokenUsed(token, new Date());
}
}
@@ -106,6 +106,9 @@ describe('POST /donations/checkout', () => {
DONATION_CURRENCY_VALUES.INR,
DONATION_CURRENCY_VALUES.PLN,
DONATION_CURRENCY_VALUES.TRY,
DONATION_CURRENCY_VALUES.SEK,
DONATION_CURRENCY_VALUES.DKK,
DONATION_CURRENCY_VALUES.NOK,
])('accepts %s currency', async (currency) => {
const amount_cents = getDonationAmountConstraints(currency).minimumAmountMinor;
const response = await createDonationCheckoutBuilder(harness)
@@ -13,10 +13,15 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa
import {createPwnedPasswordsRangeHandler} from '@app/api/test/msw/handlers/PwnedPasswordsHandlers';
import {createStripeApiHandlers, type StripeApiHandlers} from '@app/api/test/msw/handlers/StripeApiHandlers';
import {server} from '@app/api/test/msw/server';
import {createBuilderWithoutAuth, type TestRequestBuilder} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
describe('GET /donations/manage', () => {
type DonationManageAlert = 'link_expired' | 'link_used' | 'link_invalid' | 'no_customer' | 'portal_error';
const STRIPE_PORTAL_URL_PATTERN = /^https:\/\/billing\.stripe\.com\/p\/session\//;
describe('GET and POST /donations/manage', () => {
let harness: ApiTestHarness;
let stripeHandlers: StripeApiHandlers;
let donationRepository: DonationRepository;
@@ -33,6 +38,32 @@ describe('GET /donations/manage', () => {
stripeHandlers.reset();
server.use(...stripeHandlers.handlers);
});
function createDonationRedeemBuilder(token: string): TestRequestBuilder<void> {
return createBuilderWithoutAuth<void>(harness).post(`/donations/manage?token=${encodeURIComponent(token)}`);
}
async function openManageLink(token: string): Promise<string | null> {
const {response} = await createDonationManageBuilder(harness, token).expect(302).executeWithResponse();
return response.headers.get('location');
}
async function redeemManageLink(token: string): Promise<string | null> {
const {response} = await createDonationRedeemBuilder(token).expect(302).executeWithResponse();
return response.headers.get('location');
}
function confirmUrl(token: string): string {
return `${Config.endpoints.marketing}/donate/manage/confirm?token=${encodeURIComponent(token)}`;
}
function alertUrl(alert: DonationManageAlert): string {
return `${Config.endpoints.marketing}/donate/manage?alert=${alert}`;
}
async function isTokenUsed(token: string): Promise<boolean | undefined> {
const tokenModel = await donationRepository.findMagicLinkToken(token);
return tokenModel?.isUsed();
}
function useFailingPortalHandlers(): void {
server.resetHandlers();
const failingHandlers = createStripeApiHandlers({portalShouldFail: true});
server.use(createPwnedPasswordsRangeHandler(), ...failingHandlers.handlers);
}
async function createDonorWithCustomerId(email: string, customerId: string): Promise<void> {
await donationRepository.createDonor({
email,
@@ -47,29 +78,21 @@ describe('GET /donations/manage', () => {
subscriptionCurrentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
});
}
async function createValidMagicLinkToken(email: string, token: string): Promise<void> {
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
const tokenModel = new DonorMagicLinkToken({
token_: token,
donor_email: email,
expires_at: expiresAt,
used_at: null,
async function createDonorWithoutSubscription(email: string, customerId: string | null): Promise<void> {
await donationRepository.createDonor({
email,
stripeCustomerId: customerId,
businessName: null,
taxId: null,
taxIdType: null,
stripeSubscriptionId: null,
subscriptionAmountCents: null,
subscriptionCurrency: null,
subscriptionInterval: null,
subscriptionCurrentPeriodEnd: null,
});
await donationRepository.createMagicLinkToken(tokenModel);
}
async function createExpiredMagicLinkToken(email: string, token: string): Promise<void> {
const expiresAt = new Date(Date.now() - 1000);
const tokenModel = new DonorMagicLinkToken({
token_: token,
donor_email: email,
expires_at: expiresAt,
used_at: null,
});
await donationRepository.createMagicLinkToken(tokenModel);
}
async function createUsedMagicLinkToken(email: string, token: string): Promise<void> {
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
const usedAt = new Date(Date.now() - 5000);
async function createMagicLinkToken(email: string, token: string, expiresAt: Date, usedAt: Date | null) {
const tokenModel = new DonorMagicLinkToken({
token_: token,
donor_email: email,
@@ -78,242 +101,279 @@ describe('GET /donations/manage', () => {
});
await donationRepository.createMagicLinkToken(tokenModel);
}
describe('valid token with customer ID', () => {
async function createValidMagicLinkToken(email: string, token: string): Promise<void> {
await createMagicLinkToken(email, token, new Date(Date.now() + 15 * 60 * 1000), null);
}
async function createExpiredMagicLinkToken(email: string, token: string): Promise<void> {
await createMagicLinkToken(email, token, new Date(Date.now() - 1000), null);
}
async function createUsedMagicLinkToken(email: string, token: string): Promise<void> {
await createMagicLinkToken(email, token, new Date(Date.now() + 15 * 60 * 1000), new Date(Date.now() - 5000));
}
describe('GET with a valid token and customer ID', () => {
test('redirects to the confirmation page', async () => {
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_valid_123');
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
});
test('does not open a Stripe billing portal session', async () => {
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_valid_123');
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await openManageLink(TEST_MAGIC_LINK_TOKEN);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
});
describe('link scanner prefetch', () => {
test('GET does not mark token as used', async () => {
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_prefetch');
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await openManageLink(TEST_MAGIC_LINK_TOKEN);
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(false);
});
test('repeated GETs keep redirecting to the confirmation page', async () => {
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_prefetch');
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(false);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('POST still redeems a token after repeated GETs', async () => {
const customerId = 'cus_test_prefetch';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await openManageLink(TEST_MAGIC_LINK_TOKEN);
await openManageLink(TEST_MAGIC_LINK_TOKEN);
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toMatch(STRIPE_PORTAL_URL_PATTERN);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
expect(stripeHandlers.spies.createdPortalSessions[0]?.customer).toBe(customerId);
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(true);
});
});
describe('POST with a valid token and customer ID', () => {
test('redirects to Stripe billing portal', async () => {
const customerId = 'cus_test_valid_123';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
const {response} = await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
expect(response.status).toBe(302);
expect(response.headers.get('location')).toContain('https://billing.stripe.com');
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toMatch(STRIPE_PORTAL_URL_PATTERN);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
expect(stripeHandlers.spies.createdPortalSessions[0]?.customer).toBe(customerId);
});
test('marks token as used after validation', async () => {
const customerId = 'cus_test_valid_456';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
test('marks token as used after the portal session is created', async () => {
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_valid_456');
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
const token = await donationRepository.findMagicLinkToken(TEST_MAGIC_LINK_TOKEN);
expect(token?.isUsed()).toBe(true);
await redeemManageLink(TEST_MAGIC_LINK_TOKEN);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(true);
});
test('includes return_url in portal session', async () => {
const customerId = 'cus_test_return_url';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_return_url');
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
await redeemManageLink(TEST_MAGIC_LINK_TOKEN);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
const portalSession = stripeHandlers.spies.createdPortalSessions[0];
expect(portalSession?.return_url).toBeDefined();
expect(portalSession?.return_url).toBe(`${Config.endpoints.marketing}/donate`);
});
});
describe('valid token without customer ID', () => {
test('redirects to donation page when donor has no customer ID', async () => {
await donationRepository.createDonor({
email: TEST_DONOR_EMAIL,
stripeCustomerId: null,
businessName: null,
taxId: null,
taxIdType: null,
stripeSubscriptionId: null,
subscriptionAmountCents: null,
subscriptionCurrency: null,
subscriptionInterval: null,
subscriptionCurrentPeriodEnd: null,
});
test('redirects to no_customer alert when donor has no customer ID', async () => {
await createDonorWithoutSubscription(TEST_DONOR_EMAIL, null);
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
const {response} = await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
expect(response.status).toBe(302);
const location = response.headers.get('location');
expect(location).toContain(Config.endpoints.marketing);
expect(location).toContain('/donate');
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('no_customer'));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('no_customer'));
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('redirects to donation page when donor does not exist', async () => {
test('redirects to no_customer alert when donor does not exist', async () => {
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
const {response} = await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
expect(response.status).toBe(302);
const location = response.headers.get('location');
expect(location).toContain(Config.endpoints.marketing);
expect(location).toContain('/donate');
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('no_customer'));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('no_customer'));
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('marks token as used even when redirecting to donate page', async () => {
test('does not mark token as used when there is no customer', async () => {
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
const token = await donationRepository.findMagicLinkToken(TEST_MAGIC_LINK_TOKEN);
expect(token?.isUsed()).toBe(true);
await openManageLink(TEST_MAGIC_LINK_TOKEN);
await redeemManageLink(TEST_MAGIC_LINK_TOKEN);
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(false);
});
});
describe('expired token handling', () => {
test('rejects expired token', async () => {
const customerId = 'cus_test_expired';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_expired');
await createExpiredMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_EXPIRED)
.execute();
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('does not mark expired token as used', async () => {
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test');
await createExpiredMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_EXPIRED)
.execute();
const token = await donationRepository.findMagicLinkToken(TEST_MAGIC_LINK_TOKEN);
expect(token?.isUsed()).toBe(false);
await openManageLink(TEST_MAGIC_LINK_TOKEN);
await redeemManageLink(TEST_MAGIC_LINK_TOKEN);
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(false);
});
test('rejects token expired by exactly 1 millisecond', async () => {
const expiresAt = new Date(Date.now() - 1);
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test');
const tokenModel = new DonorMagicLinkToken({
token_: TEST_MAGIC_LINK_TOKEN,
donor_email: TEST_DONOR_EMAIL,
expires_at: expiresAt,
used_at: null,
});
await donationRepository.createMagicLinkToken(tokenModel);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_EXPIRED)
.execute();
await createMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN, new Date(Date.now() - 1), null);
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
});
describe('used token handling', () => {
test('rejects already-used token', async () => {
const customerId = 'cus_test_used';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_used');
await createUsedMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_USED)
.execute();
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_used'));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_used'));
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('prevents reuse of token after successful redemption', async () => {
const customerId = 'cus_test_reuse';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_reuse');
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_USED)
.execute();
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toMatch(STRIPE_PORTAL_URL_PATTERN);
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_used'));
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_used'));
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
});
});
describe('invalid token handling', () => {
test('rejects non-existent token', async () => {
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_INVALID)
.execute();
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_invalid'));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_invalid'));
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('rejects token with invalid format', async () => {
await createDonationManageBuilder(harness, TEST_INVALID_TOKEN).expect(400).execute();
await createDonationManageBuilder(harness, TEST_INVALID_TOKEN)
.expect(400, APIErrorCodes.INVALID_FORM_BODY)
.execute();
await createDonationRedeemBuilder(TEST_INVALID_TOKEN).expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('rejects empty token', async () => {
await createDonationManageBuilder(harness, '').expect(400).execute();
await createDonationManageBuilder(harness, '').expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
await createDonationRedeemBuilder('').expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('rejects token that is too short', async () => {
const shortToken = 'a'.repeat(63);
await createDonationManageBuilder(harness, shortToken).expect(400).execute();
await createDonationManageBuilder(harness, shortToken).expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
await createDonationRedeemBuilder(shortToken).expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('rejects token that is too long', async () => {
const longToken = 'a'.repeat(65);
await createDonationManageBuilder(harness, longToken).expect(400).execute();
await createDonationManageBuilder(harness, longToken).expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
await createDonationRedeemBuilder(longToken).expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('rejects token with non-hex characters', async () => {
const invalidToken = 'g'.repeat(64);
await createDonationManageBuilder(harness, invalidToken).expect(400).execute();
expect(await openManageLink(invalidToken)).toBe(alertUrl('link_invalid'));
expect(await redeemManageLink(invalidToken)).toBe(alertUrl('link_invalid'));
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('rejects token with special characters', async () => {
const specialToken = `${'a'.repeat(62)}@!`;
await createDonationManageBuilder(harness, specialToken).expect(400).execute();
expect(await openManageLink(specialToken)).toBe(alertUrl('link_invalid'));
expect(await redeemManageLink(specialToken)).toBe(alertUrl('link_invalid'));
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('rejects token with whitespace', async () => {
const whitespaceToken = `${'a'.repeat(32)} ${'a'.repeat(31)}`;
await createDonationManageBuilder(harness, whitespaceToken).expect(400).execute();
expect(await openManageLink(whitespaceToken)).toBe(alertUrl('link_invalid'));
expect(await redeemManageLink(whitespaceToken)).toBe(alertUrl('link_invalid'));
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
});
describe('missing token parameter', () => {
test('rejects request without token query parameter', async () => {
const response = await harness.requestJson({
const getResponse = await harness.requestJson({
path: '/donations/manage',
method: 'GET',
});
expect(response.status).toBe(400);
expect(getResponse.status).toBe(400);
const postResponse = await harness.requestJson({
path: '/donations/manage',
method: 'POST',
});
expect(postResponse.status).toBe(400);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
});
describe('Stripe portal session creation', () => {
test('handles Stripe API failure gracefully', async () => {
const customerId = 'cus_test_stripe_fail';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_stripe_fail');
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
useFailingPortalHandlers();
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('portal_error'));
});
test('does not mark token as used when the portal session fails', async () => {
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_stripe_fail');
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
useFailingPortalHandlers();
await redeemManageLink(TEST_MAGIC_LINK_TOKEN);
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(false);
});
test('redeems the same token once Stripe recovers', async () => {
const customerId = 'cus_test_stripe_retry';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
useFailingPortalHandlers();
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('portal_error'));
server.resetHandlers();
const failingHandlers = createStripeApiHandlers({portalShouldFail: true});
server.use(createPwnedPasswordsRangeHandler(), ...failingHandlers.handlers);
const {response} = await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
expect(response.status).toBe(400);
server.use(...stripeHandlers.handlers);
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toMatch(STRIPE_PORTAL_URL_PATTERN);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
expect(stripeHandlers.spies.createdPortalSessions[0]?.customer).toBe(customerId);
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(true);
});
test('creates portal session with correct customer ID', async () => {
const customerId = 'cus_test_specific_123';
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
await redeemManageLink(TEST_MAGIC_LINK_TOKEN);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
const session = stripeHandlers.spies.createdPortalSessions[0];
expect(session?.customer).toBe(customerId);
});
});
describe('edge cases', () => {
test('handles token for donor with cancelled subscription', async () => {
await donationRepository.createDonor({
email: TEST_DONOR_EMAIL,
stripeCustomerId: 'cus_test_cancelled',
businessName: null,
taxId: null,
taxIdType: null,
stripeSubscriptionId: null,
subscriptionAmountCents: null,
subscriptionCurrency: null,
subscriptionInterval: null,
subscriptionCurrentPeriodEnd: null,
});
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
const {response} = await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
expect(response.status).toBe(302);
expect(response.headers.get('location')).toContain('https://billing.stripe.com');
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
afterEach(() => {
vi.useRealTimers();
});
test('handles token expiring in exactly 0 milliseconds', async () => {
const expiresAt = new Date(Date.now() - 1);
test('handles token for donor with cancelled subscription', async () => {
const customerId = 'cus_test_cancelled';
await createDonorWithoutSubscription(TEST_DONOR_EMAIL, customerId);
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toMatch(STRIPE_PORTAL_URL_PATTERN);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
expect(stripeHandlers.spies.createdPortalSessions[0]?.customer).toBe(customerId);
});
test('handles token at its exact expiry instant', async () => {
const expiresAt = new Date();
vi.useFakeTimers({toFake: ['Date']});
vi.setSystemTime(expiresAt);
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test');
const tokenModel = new DonorMagicLinkToken({
token_: TEST_MAGIC_LINK_TOKEN,
donor_email: TEST_DONOR_EMAIL,
expires_at: expiresAt,
used_at: null,
});
await donationRepository.createMagicLinkToken(tokenModel);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_EXPIRED)
.execute();
await createMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN, expiresAt, null);
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
vi.setSystemTime(expiresAt.getTime() + 1);
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(false);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('handles case-sensitive token comparison', async () => {
const lowerToken = 'a'.repeat(64);
const upperToken = 'A'.repeat(64);
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test');
await createValidMagicLinkToken(TEST_DONOR_EMAIL, lowerToken);
await createDonationManageBuilder(harness, upperToken)
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_INVALID)
.execute();
expect(await openManageLink(upperToken)).toBe(alertUrl('link_invalid'));
expect(await redeemManageLink(upperToken)).toBe(alertUrl('link_invalid'));
expect(await isTokenUsed(lowerToken)).toBe(false);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
});
test('handles multiple valid tokens for different donors', async () => {
const token1 = 'a'.repeat(64);
@@ -326,35 +386,37 @@ describe('GET /donations/manage', () => {
await createDonorWithCustomerId(email2, customerId2);
await createValidMagicLinkToken(email1, token1);
await createValidMagicLinkToken(email2, token2);
const {response: response1} = await createDonationManageBuilder(harness, token1).executeRaw();
expect(response1.status).toBe(302);
expect(await openManageLink(token1)).toBe(confirmUrl(token1));
expect(await openManageLink(token2)).toBe(confirmUrl(token2));
expect(await redeemManageLink(token1)).toMatch(STRIPE_PORTAL_URL_PATTERN);
expect(stripeHandlers.spies.createdPortalSessions[0]?.customer).toBe(customerId1);
const {response: response2} = await createDonationManageBuilder(harness, token2).executeRaw();
expect(response2.status).toBe(302);
expect(await isTokenUsed(token1)).toBe(true);
expect(await isTokenUsed(token2)).toBe(false);
expect(await redeemManageLink(token2)).toMatch(STRIPE_PORTAL_URL_PATTERN);
expect(stripeHandlers.spies.createdPortalSessions[1]?.customer).toBe(customerId2);
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(2);
});
});
describe('token validation order', () => {
test('checks token existence before expiration', async () => {
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_INVALID)
.execute();
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_invalid'));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_invalid'));
});
test('checks expiration before usage status', async () => {
const expiresAt = new Date(Date.now() - 1000);
const usedAt = new Date(Date.now() - 5000);
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test');
const tokenModel = new DonorMagicLinkToken({
token_: TEST_MAGIC_LINK_TOKEN,
donor_email: TEST_DONOR_EMAIL,
expires_at: expiresAt,
used_at: usedAt,
});
await donationRepository.createMagicLinkToken(tokenModel);
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_EXPIRED)
.execute();
await createMagicLinkToken(
TEST_DONOR_EMAIL,
TEST_MAGIC_LINK_TOKEN,
new Date(Date.now() - 1000),
new Date(Date.now() - 5000),
);
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
});
test('checks usage status before customer lookup', async () => {
await createUsedMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_used'));
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_used'));
});
});
});
@@ -112,8 +112,12 @@ describe('POST /donations/request-link', () => {
const longEmail = `${longLocalPart}@example.com`;
await createDonationRequestLinkBuilder(harness).body({email: longEmail}).expect(400).execute();
});
test('rejects email with leading/trailing whitespace', async () => {
await createDonationRequestLinkBuilder(harness).body({email: ' [email protected] '}).expect(400).execute();
test('normalizes surrounding whitespace and letter case to the stored donor', async () => {
await createDonor(TEST_DONOR_EMAIL);
await createDonationRequestLinkBuilder(harness)
.body({email: ` ${TEST_DONOR_EMAIL.toUpperCase()} `})
.expect(204)
.execute();
});
});
describe('idempotency', () => {
@@ -52,6 +52,9 @@ export const DONATION_CURRENCY_VALUES = {
INR: DONATION_CURRENCIES[3],
PLN: DONATION_CURRENCIES[4],
TRY: DONATION_CURRENCIES[5],
SEK: DONATION_CURRENCIES[6],
DKK: DONATION_CURRENCIES[7],
NOK: DONATION_CURRENCIES[8],
} as const;
export const DONATION_INTERVALS = {
MONTH: 'month',
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {resolveExpressionSourceGuild} from '@app/api/guild/ExpressionSourceGuild';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {describe, expect, it, vi} from 'vitest';
function guild(features: Array<string>, icon: string | null = null): GuildResponse {
return {id: '20', name: 'Blob Club', icon, features} as unknown as GuildResponse;
}
describe('resolveExpressionSourceGuild', () => {
it('hides a community the gateway reports as unavailable without checking membership', async () => {
const isMember = vi.fn(() => Promise.resolve(true));
await expect(
resolveExpressionSourceGuild({loadGuild: () => Promise.reject(new UnknownGuildError()), isMember}),
).rejects.toBeInstanceOf(UnknownGuildError);
expect(isMember).not.toHaveBeenCalled();
});
it('propagates other gateway failures instead of reporting the community as unavailable', async () => {
const failure = new Error('gateway timeout');
const isMember = vi.fn(() => Promise.resolve(true));
await expect(resolveExpressionSourceGuild({loadGuild: () => Promise.reject(failure), isMember})).rejects.toBe(
failure,
);
expect(isMember).not.toHaveBeenCalled();
});
it('reveals a discoverable community without checking membership', async () => {
const isMember = vi.fn(() => Promise.resolve(false));
const community = await resolveExpressionSourceGuild({
loadGuild: () => Promise.resolve(guild([GuildFeatures.DISCOVERABLE])),
isMember,
});
expect(community).toEqual({id: '20', name: 'Blob Club', icon: null, features: [GuildFeatures.DISCOVERABLE]});
expect(isMember).not.toHaveBeenCalled();
});
it('reveals a private community to a member', async () => {
const community = await resolveExpressionSourceGuild({
loadGuild: () => Promise.resolve(guild([])),
isMember: () => Promise.resolve(true),
});
expect(community.id).toBe('20');
expect(community.features).toEqual([]);
});
it('hides a private community from a non-member', async () => {
await expect(
resolveExpressionSourceGuild({
loadGuild: () => Promise.resolve(guild([])),
isMember: () => Promise.resolve(false),
}),
).rejects.toBeInstanceOf(UnknownGuildError);
});
it('limits features to the badge flags', async () => {
const community = await resolveExpressionSourceGuild({
loadGuild: () =>
Promise.resolve(
guild([
GuildFeatures.DISCOVERABLE,
GuildFeatures.VERIFIED,
GuildFeatures.INVITES_DISABLED,
GuildFeatures.CLONE_EMOJI_ENABLED,
]),
),
isMember: () => Promise.resolve(false),
});
expect(community.features).toEqual(expect.arrayContaining([GuildFeatures.DISCOVERABLE, GuildFeatures.VERIFIED]));
expect(community.features).not.toContain(GuildFeatures.INVITES_DISABLED);
expect(community.features).not.toContain(GuildFeatures.CLONE_EMOJI_ENABLED);
});
it('strips the animated icon prefix unless the community is entitled to it', async () => {
const plain = await resolveExpressionSourceGuild({
loadGuild: () => Promise.resolve(guild([GuildFeatures.DISCOVERABLE], 'a_icon')),
isMember: () => Promise.resolve(false),
});
const animated = await resolveExpressionSourceGuild({
loadGuild: () => Promise.resolve(guild([GuildFeatures.DISCOVERABLE, GuildFeatures.ANIMATED_ICON], 'a_icon')),
isMember: () => Promise.resolve(false),
});
expect(plain.icon).toBe('icon');
expect(animated.icon).toBe('a_icon');
});
});

Some files were not shown because too many files have changed in this diff Show More