From 8ee2279b4b7834f6835259cf5cdb1dae1ab9e92b Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 14 Sep 2026 20:27:24 +0200 Subject: [PATCH] feat(donations): add Nordic currencies, raise amount ceilings (#2775) --- fluxer_api/package.json | 3 +- fluxer_api/pkgs/email/src/EmailService.ts | 9 +- .../email/src/email_i18n/EmailI18nMessages.ts | 2 +- .../pkgs/email/src/email_i18n/locales/ar.ts | 2 +- .../pkgs/email/src/email_i18n/locales/bg.ts | 2 +- .../pkgs/email/src/email_i18n/locales/cs.ts | 2 +- .../pkgs/email/src/email_i18n/locales/da.ts | 2 +- .../pkgs/email/src/email_i18n/locales/de.ts | 2 +- .../pkgs/email/src/email_i18n/locales/el.ts | 2 +- .../email/src/email_i18n/locales/en-GB.ts | 2 +- .../email/src/email_i18n/locales/es-419.ts | 2 +- .../email/src/email_i18n/locales/es-ES.ts | 2 +- .../pkgs/email/src/email_i18n/locales/fi.ts | 2 +- .../pkgs/email/src/email_i18n/locales/fr.ts | 2 +- .../pkgs/email/src/email_i18n/locales/he.ts | 2 +- .../pkgs/email/src/email_i18n/locales/hi.ts | 2 +- .../pkgs/email/src/email_i18n/locales/hr.ts | 2 +- .../pkgs/email/src/email_i18n/locales/hu.ts | 2 +- .../pkgs/email/src/email_i18n/locales/id.ts | 2 +- .../pkgs/email/src/email_i18n/locales/it.ts | 2 +- .../pkgs/email/src/email_i18n/locales/ja.ts | 2 +- .../pkgs/email/src/email_i18n/locales/ko.ts | 2 +- .../pkgs/email/src/email_i18n/locales/lt.ts | 2 +- .../pkgs/email/src/email_i18n/locales/nl.ts | 2 +- .../pkgs/email/src/email_i18n/locales/no.ts | 2 +- .../pkgs/email/src/email_i18n/locales/pl.ts | 2 +- .../email/src/email_i18n/locales/pt-BR.ts | 2 +- .../pkgs/email/src/email_i18n/locales/ro.ts | 2 +- .../pkgs/email/src/email_i18n/locales/ru.ts | 2 +- .../email/src/email_i18n/locales/sv-SE.ts | 2 +- .../pkgs/email/src/email_i18n/locales/th.ts | 2 +- .../pkgs/email/src/email_i18n/locales/tr.ts | 2 +- .../pkgs/email/src/email_i18n/locales/uk.ts | 2 +- .../pkgs/email/src/email_i18n/locales/vi.ts | 2 +- .../email/src/email_i18n/locales/zh-CN.ts | 2 +- .../email/src/email_i18n/locales/zh-TW.ts | 2 +- .../src/email_i18n/weblate/locales/ar.json | 2 +- .../src/email_i18n/weblate/locales/bg.json | 2 +- .../src/email_i18n/weblate/locales/cs.json | 2 +- .../src/email_i18n/weblate/locales/da.json | 2 +- .../src/email_i18n/weblate/locales/de.json | 2 +- .../src/email_i18n/weblate/locales/el.json | 2 +- .../src/email_i18n/weblate/locales/en-GB.json | 2 +- .../email_i18n/weblate/locales/es-419.json | 2 +- .../src/email_i18n/weblate/locales/es-ES.json | 2 +- .../src/email_i18n/weblate/locales/fi.json | 2 +- .../src/email_i18n/weblate/locales/fr.json | 2 +- .../src/email_i18n/weblate/locales/he.json | 2 +- .../src/email_i18n/weblate/locales/hi.json | 2 +- .../src/email_i18n/weblate/locales/hr.json | 2 +- .../src/email_i18n/weblate/locales/hu.json | 2 +- .../src/email_i18n/weblate/locales/id.json | 2 +- .../src/email_i18n/weblate/locales/it.json | 2 +- .../src/email_i18n/weblate/locales/ja.json | 2 +- .../src/email_i18n/weblate/locales/ko.json | 2 +- .../src/email_i18n/weblate/locales/lt.json | 2 +- .../src/email_i18n/weblate/locales/nl.json | 2 +- .../src/email_i18n/weblate/locales/no.json | 2 +- .../src/email_i18n/weblate/locales/pl.json | 2 +- .../src/email_i18n/weblate/locales/pt-BR.json | 2 +- .../src/email_i18n/weblate/locales/ro.json | 2 +- .../src/email_i18n/weblate/locales/ru.json | 2 +- .../src/email_i18n/weblate/locales/sv-SE.json | 2 +- .../src/email_i18n/weblate/locales/th.json | 2 +- .../src/email_i18n/weblate/locales/tr.json | 2 +- .../src/email_i18n/weblate/locales/uk.json | 2 +- .../src/email_i18n/weblate/locales/vi.json | 2 +- .../src/email_i18n/weblate/locales/zh-CN.json | 2 +- .../src/email_i18n/weblate/locales/zh-TW.json | 2 +- .../src/email_i18n/weblate/messages.json | 2 +- fluxer_api/scripts/BackfillDonorEmailCase.ts | 232 ++++++++++ fluxer_api/src/api/Config.ts | 5 + fluxer_api/src/api/config/APIConfig.ts | 1 + .../src/api/database/types/DonationTypes.ts | 2 + .../src/api/donation/DonationController.ts | 78 +++- .../src/api/donation/DonationRepository.ts | 94 ++++- .../src/api/donation/DonationService.ts | 17 +- .../src/api/donation/IDonationRepository.ts | 18 + .../src/api/donation/IDonationService.ts | 2 +- fluxer_api/src/api/donation/models/Donor.ts | 10 + .../services/DonationCheckoutService.ts | 113 ++++- .../services/DonationMagicLinkService.ts | 5 +- .../donation/tests/DonationCheckout.test.ts | 3 + .../api/donation/tests/DonationManage.test.ts | 396 ++++++++++-------- .../tests/DonationRequestLink.test.ts | 8 +- .../api/donation/tests/DonationTestUtils.ts | 3 + .../src/api/middleware/RateLimitMiddleware.ts | 72 +++- fluxer_api/src/api/openapi/openapi.json | 59 ++- .../DonationRateLimitConfig.ts | 11 + .../services/StripeCheckoutWebhookHandler.ts | 151 +++++-- .../services/StripeDisputeWebhookHandler.ts | 41 ++ .../StripeSubscriptionWebhookHandler.ts | 113 ++++- .../stripe/services/StripeWebhookService.ts | 3 +- fluxer_docs/scripts/VerifyDocsStyle.ts | 2 +- .../src/content/docs/http-api/donations.mdx | 72 +++- .../content/docs/operator/configuration.mdx | 4 + packages/config/src/ConfigLoader.ts | 1 + packages/config/src/MasterConfig.ts | 1 + .../src/config_loader/EnvironmentOverrides.ts | 1 + .../domains/donation/DonationAmountUtils.ts | 58 +-- .../src/domains/donation/DonationSchemas.ts | 6 +- tools/dev/cassandra_target_schema.json | 4 + 102 files changed, 1333 insertions(+), 401 deletions(-) create mode 100644 fluxer_api/scripts/BackfillDonorEmailCase.ts diff --git a/fluxer_api/package.json b/fluxer_api/package.json index 313726b8a..db00cc9dc 100644 --- a/fluxer_api/package.json +++ b/fluxer_api/package.json @@ -8,7 +8,8 @@ "typecheck": "tsgo --noEmit", "dev": "tsx watch --clear-screen=false src/AppEntrypoint.ts", "start": "tsx src/AppEntrypoint.ts", - "start:worker": "tsx src/WorkerEntrypoint.ts" + "start:worker": "tsx src/WorkerEntrypoint.ts", + "backfill:donor-email-case": "tsx scripts/BackfillDonorEmailCase.ts" }, "dependencies": { "@atproto/api": "catalog:", diff --git a/fluxer_api/pkgs/email/src/EmailService.ts b/fluxer_api/pkgs/email/src/EmailService.ts index 987b74b05..8e5fe487b 100644 --- a/fluxer_api/pkgs/email/src/EmailService.ts +++ b/fluxer_api/pkgs/email/src/EmailService.ts @@ -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 { return this.sendTemplatedEmail(email, 'donation_confirmation', locale, { - amount: (amountCents / 100).toFixed(2), + amount: formatMinorUnitAmount(amountCents, currency, locale), currency: currency.toUpperCase(), interval, manageUrl, diff --git a/fluxer_api/pkgs/email/src/email_i18n/EmailI18nMessages.ts b/fluxer_api/pkgs/email/src/email_i18n/EmailI18nMessages.ts index e95dc0309..fa7a8934a 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/EmailI18nMessages.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/EmailI18nMessages.ts @@ -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', diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/ar.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/ar.ts index 4b2b14f11..c08057fab 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/ar.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/ar.ts @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/bg.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/bg.ts index c14cc58d0..45e2a9dca 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/bg.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/bg.ts @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/cs.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/cs.ts index 3790bc51f..06406ee3c 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/cs.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/cs.ts @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/da.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/da.ts index 0ec68a8ca..fa797bf78 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/da.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/da.ts @@ -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", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/de.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/de.ts index 9c84ef038..76a8d76b0 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/de.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/de.ts @@ -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", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/el.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/el.ts index 360351f11..c5c81deef 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/el.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/el.ts @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/en-GB.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/en-GB.ts index 93019fb64..7f663c214 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/en-GB.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/en-GB.ts @@ -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", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/es-419.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/es-419.ts index 9fb161fe2..d242ddc8d 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/es-419.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/es-419.ts @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/es-ES.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/es-ES.ts index 9b3564126..60784fada 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/es-ES.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/es-ES.ts @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/fi.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/fi.ts index d459dad3c..ceb0f9b96 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/fi.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/fi.ts @@ -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", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/fr.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/fr.ts index 51fa9ac62..05d18c32b 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/fr.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/fr.ts @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/he.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/he.ts index 40abadfdb..7b454c833 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/he.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/he.ts @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/hi.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/hi.ts index 89e74d7c5..ad745036f 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/hi.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/hi.ts @@ -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} डोनेशन मैनेज करें", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/hr.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/hr.ts index 800c72ad8..8bd59b0bd 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/hr.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/hr.ts @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/hu.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/hu.ts index 38f541472..da8fe3c20 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/hu.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/hu.ts @@ -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", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/id.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/id.ts index 3039d03b9..d507945b5 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/id.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/id.ts @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/it.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/it.ts index b6c58518c..d626d8367 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/it.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/it.ts @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/ja.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/ja.ts index 9e2dad2dc..66c8a5b9f 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/ja.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/ja.ts @@ -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}への寄付を管理", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/ko.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/ko.ts index 225c9ae16..45050d060 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/ko.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/ko.ts @@ -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} 후원 내역 관리", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/lt.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/lt.ts index f0ff3fdfd..65c5bbe1a 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/lt.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/lt.ts @@ -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ą", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/nl.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/nl.ts index 5a20d3696..eba5b4fa2 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/nl.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/nl.ts @@ -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", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/no.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/no.ts index 37fe57bea..11dc0d3ce 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/no.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/no.ts @@ -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", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/pl.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/pl.ts index 0097624e0..d737ff7c4 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/pl.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/pl.ts @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/pt-BR.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/pt-BR.ts index 6177f3a09..4bcf8448a 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/pt-BR.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/pt-BR.ts @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/ro.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/ro.ts index 79fcf38fb..9ed541c10 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/ro.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/ro.ts @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/ru.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/ru.ts index 50b737c05..b38a9a4d8 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/ru.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/ru.ts @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/sv-SE.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/sv-SE.ts index 65ee75d49..be5224d48 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/sv-SE.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/sv-SE.ts @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/th.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/th.ts index e0c2657fb..dcd184c98 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/th.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/th.ts @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/tr.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/tr.ts index c2bbf8ab8..2a8003eac 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/tr.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/tr.ts @@ -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", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/uk.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/uk.ts index cec248553..8e045b762 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/uk.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/uk.ts @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/vi.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/vi.ts index d399768ff..7a035a1a9 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/vi.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/vi.ts @@ -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", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/zh-CN.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/zh-CN.ts index 5b31b3d57..112b511b7 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/zh-CN.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/zh-CN.ts @@ -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} 捐赠", diff --git a/fluxer_api/pkgs/email/src/email_i18n/locales/zh-TW.ts b/fluxer_api/pkgs/email/src/email_i18n/locales/zh-TW.ts index 11d9977d5..79d531d3f 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/locales/zh-TW.ts +++ b/fluxer_api/pkgs/email/src/email_i18n/locales/zh-TW.ts @@ -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} 捐款", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/ar.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/ar.json index a7923744b..4ae6dfe49 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/ar.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/ar.json @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/bg.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/bg.json index 470de1990..99d5d7c49 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/bg.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/bg.json @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/cs.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/cs.json index 23d0d7afd..1a72cfe14 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/cs.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/cs.json @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/da.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/da.json index 7c32afe30..73a0b5646 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/da.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/da.json @@ -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", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/de.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/de.json index 80b1cd69f..c8d431d63 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/de.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/de.json @@ -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", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/el.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/el.json index ec32a2b99..1b4645d73 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/el.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/el.json @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/en-GB.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/en-GB.json index 3c92b1b7d..2d41ade89 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/en-GB.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/en-GB.json @@ -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", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/es-419.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/es-419.json index ba54d9b28..0ffa7268e 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/es-419.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/es-419.json @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/es-ES.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/es-ES.json index 940850e52..78409e5e0 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/es-ES.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/es-ES.json @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/fi.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/fi.json index 07ca558ee..dbd748211 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/fi.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/fi.json @@ -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", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/fr.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/fr.json index 6b11059f3..2c0a5c59f 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/fr.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/fr.json @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/he.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/he.json index 9ae448a3b..02562ab12 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/he.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/he.json @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/hi.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/hi.json index 3c59a1bd6..ec99f1f36 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/hi.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/hi.json @@ -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} डोनेशन मैनेज करें", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/hr.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/hr.json index 5c21cdfe2..6856fdbc2 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/hr.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/hr.json @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/hu.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/hu.json index 98c004f24..7fd10ab14 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/hu.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/hu.json @@ -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", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/id.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/id.json index 8dbbf5a60..6552324f4 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/id.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/id.json @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/it.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/it.json index 8fe472ca5..ebc3ad424 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/it.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/it.json @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/ja.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/ja.json index ed27427fd..92b395f4e 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/ja.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/ja.json @@ -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}への寄付を管理", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/ko.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/ko.json index ad0de3bce..70039cc05 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/ko.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/ko.json @@ -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} 후원 내역 관리", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/lt.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/lt.json index 1007f65ca..4ef0a4459 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/lt.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/lt.json @@ -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ą", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/nl.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/nl.json index 5abd90e0b..eac3b3b57 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/nl.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/nl.json @@ -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", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/no.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/no.json index 9abab1194..6f83f06de 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/no.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/no.json @@ -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", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/pl.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/pl.json index b0719742a..1b819dbd6 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/pl.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/pl.json @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/pt-BR.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/pt-BR.json index f6115cd68..21922ebc0 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/pt-BR.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/pt-BR.json @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/ro.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/ro.json index 27dd1e661..76cdacb6d 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/ro.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/ro.json @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/ru.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/ru.json index 541962800..30ffe9f3c 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/ru.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/ru.json @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/sv-SE.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/sv-SE.json index 0dc621e32..211111ad9 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/sv-SE.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/sv-SE.json @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/th.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/th.json index 66a7d38fe..4aa07fac5 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/th.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/th.json @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/tr.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/tr.json index 71f5b9495..3404894e4 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/tr.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/tr.json @@ -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", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/uk.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/uk.json index 2652bccec..244a92cf4 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/uk.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/uk.json @@ -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}", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/vi.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/vi.json index fcb66f515..b30d33f2d 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/vi.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/vi.json @@ -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", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/zh-CN.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/zh-CN.json index 4b2976274..85e7da610 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/zh-CN.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/zh-CN.json @@ -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} 捐赠", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/zh-TW.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/zh-TW.json index 5c8039025..ccf0ccb44 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/zh-TW.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/locales/zh-TW.json @@ -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} 捐款", diff --git a/fluxer_api/pkgs/email/src/email_i18n/weblate/messages.json b/fluxer_api/pkgs/email/src/email_i18n/weblate/messages.json index 662fc2ab9..95f78639d 100644 --- a/fluxer_api/pkgs/email/src/email_i18n/weblate/messages.json +++ b/fluxer_api/pkgs/email/src/email_i18n/weblate/messages.json @@ -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", diff --git a/fluxer_api/scripts/BackfillDonorEmailCase.ts b/fluxer_api/scripts/BackfillDonorEmailCase.ts new file mode 100644 index 000000000..5124687df --- /dev/null +++ b/fluxer_api/scripts/BackfillDonorEmailCase.ts @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {initializeConfig} from '@app/api/Config'; +import { + BatchBuilder, + fetchOne, + fetchPage, + type PagedQueryResult, + setDatabaseQueryExecutor, + upsertOne, +} from '@app/api/database/CassandraQueryExecution'; +import {Db} from '@app/api/database/CassandraTypes'; +import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from '@app/api/database/PostgresKvQueryExecutor'; +import type { + DonorByStripeCustomerIdRow, + DonorByStripeSubscriptionIdRow, + DonorMagicLinkTokenByEmailRow, + DonorMagicLinkTokenRow, + DonorRow, +} from '@app/api/database/types/DonationTypes'; +import { + DonorMagicLinkTokens, + DonorMagicLinkTokensByEmail, + Donors, + DonorsByStripeCustomerId, + DonorsByStripeSubscriptionId, +} from '@app/api/donation/DonationTables'; +import {initializeLogger} from '@app/api/Logger'; +import {Config} from '@app/Config'; +import {Logger} from '@app/Logger'; +import {BACKGROUND_READ_TIMEOUT_MS, initCassandra, shutdownCassandra} from '@pkgs/cassandra/src/Client'; +import {getDefaultPostgresClient, initPostgres, shutdownPostgres} from '@pkgs/postgres/src/Client'; + +const PAGE_SIZE = 500; + +const SCAN_DONORS_QUERY = Donors.selectCql({}); +const FETCH_DONOR_BY_EMAIL_QUERY = Donors.selectCql({ + where: Donors.where.eq('email'), + limit: 1, +}); +const SCAN_DONORS_BY_STRIPE_CUSTOMER_ID_QUERY = DonorsByStripeCustomerId.selectCql({}); +const SCAN_DONORS_BY_STRIPE_SUBSCRIPTION_ID_QUERY = DonorsByStripeSubscriptionId.selectCql({}); +const SCAN_MAGIC_LINK_TOKENS_QUERY = DonorMagicLinkTokens.selectCql({}); +const SCAN_MAGIC_LINK_TOKENS_BY_EMAIL_QUERY = DonorMagicLinkTokensByEmail.selectCql({}); + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +async function scanTable(query: string): Promise> { + const rows: Array = []; + let pageState: string | null = null; + do { + const page: PagedQueryResult = await fetchPage(query, {}, {pageSize: PAGE_SIZE, pageState}); + rows.push(...page.rows); + pageState = page.pageState; + } while (pageState !== null); + return rows; +} + +async function backfillDonors(apply: boolean): Promise> { + const rows = await scanTable(SCAN_DONORS_QUERY); + const conflicted = new Set(); + let moved = 0; + for (const row of rows) { + const email = normalizeEmail(row.email); + if (email === row.email) continue; + const existing = await fetchOne(FETCH_DONOR_BY_EMAIL_QUERY, {email}); + if (existing) { + conflicted.add(row.email); + Logger.warn( + {email, original: row.email}, + 'Donor already exists under the normalized email, leaving both rows and every row that points at them in place', + ); + continue; + } + if (apply) { + const batch = new BatchBuilder(); + batch.addPrepared(Donors.upsertAll({...row, email})); + batch.addPrepared(Donors.deleteByPk({email: row.email})); + await batch.execute(); + } + moved += 1; + } + Logger.info({scanned: rows.length, moved, conflicts: conflicted.size}, 'Donor rows processed'); + return conflicted; +} + +async function backfillDonorsByStripeCustomerId(apply: boolean, conflicted: Set): Promise { + const rows = await scanTable(SCAN_DONORS_BY_STRIPE_CUSTOMER_ID_QUERY); + let moved = 0; + let skipped = 0; + for (const row of rows) { + const email = normalizeEmail(row.email); + if (email === row.email) continue; + if (conflicted.has(row.email)) { + skipped += 1; + continue; + } + if (apply) { + const batch = new BatchBuilder(); + batch.addPrepared(DonorsByStripeCustomerId.upsertAll({stripe_customer_id: row.stripe_customer_id, email})); + batch.addPrepared( + DonorsByStripeCustomerId.deleteByPk({stripe_customer_id: row.stripe_customer_id, email: row.email}), + ); + await batch.execute(); + } + moved += 1; + } + Logger.info({scanned: rows.length, moved, skipped}, 'Donor stripe customer index rows processed'); +} + +async function backfillDonorsByStripeSubscriptionId(apply: boolean, conflicted: Set): Promise { + const rows = await scanTable(SCAN_DONORS_BY_STRIPE_SUBSCRIPTION_ID_QUERY); + let moved = 0; + let skipped = 0; + for (const row of rows) { + const email = normalizeEmail(row.email); + if (email === row.email) continue; + if (conflicted.has(row.email)) { + skipped += 1; + continue; + } + if (apply) { + const batch = new BatchBuilder(); + batch.addPrepared( + DonorsByStripeSubscriptionId.upsertAll({stripe_subscription_id: row.stripe_subscription_id, email}), + ); + batch.addPrepared( + DonorsByStripeSubscriptionId.deleteByPk({ + stripe_subscription_id: row.stripe_subscription_id, + email: row.email, + }), + ); + await batch.execute(); + } + moved += 1; + } + Logger.info({scanned: rows.length, moved, skipped}, 'Donor stripe subscription index rows processed'); +} + +async function backfillMagicLinkTokens(apply: boolean, conflicted: Set): Promise { + const rows = await scanTable(SCAN_MAGIC_LINK_TOKENS_QUERY); + let updated = 0; + let skipped = 0; + for (const row of rows) { + const donorEmail = normalizeEmail(row.donor_email); + if (donorEmail === row.donor_email) continue; + if (conflicted.has(row.donor_email)) { + skipped += 1; + continue; + } + if (apply) { + await upsertOne(DonorMagicLinkTokens.patchByPk({token_: row.token_}, {donor_email: Db.set(donorEmail)})); + } + updated += 1; + } + Logger.info({scanned: rows.length, updated, skipped}, 'Donor magic link token rows processed'); +} + +async function backfillMagicLinkTokensByEmail(apply: boolean, conflicted: Set): Promise { + const rows = await scanTable(SCAN_MAGIC_LINK_TOKENS_BY_EMAIL_QUERY); + let moved = 0; + let skipped = 0; + for (const row of rows) { + const donorEmail = normalizeEmail(row.donor_email); + if (donorEmail === row.donor_email) continue; + if (conflicted.has(row.donor_email)) { + skipped += 1; + continue; + } + if (apply) { + const batch = new BatchBuilder(); + batch.addPrepared(DonorMagicLinkTokensByEmail.upsertAll({donor_email: donorEmail, token_: row.token_})); + batch.addPrepared(DonorMagicLinkTokensByEmail.deleteByPk({donor_email: row.donor_email, token_: row.token_})); + await batch.execute(); + } + moved += 1; + } + Logger.info({scanned: rows.length, moved, skipped}, 'Donor magic link token index rows processed'); +} + +async function main(): Promise { + initializeConfig(Config); + initializeLogger(Logger); + const apply = process.argv.includes('--apply'); + let cassandraInitialized = false; + let postgresInitialized = false; + if (Config.database.backend === 'postgres') { + await initPostgres(Config.postgres, (diagnostic) => Logger.error({diagnostic}, 'Postgres connection error')); + postgresInitialized = true; + const postgres = getDefaultPostgresClient(); + await ensurePostgresKvSchema(postgres); + setDatabaseQueryExecutor(new PostgresKvQueryExecutor(postgres)); + } + if (Config.database.backend === 'cassandra') { + await initCassandra({ + hosts: Config.cassandra.hosts.split(',').filter(Boolean), + port: Config.cassandra.port, + keyspace: Config.cassandra.keyspace, + localDc: Config.cassandra.localDc, + username: Config.cassandra.username || undefined, + password: Config.cassandra.password || undefined, + readTimeoutMs: BACKGROUND_READ_TIMEOUT_MS, + }); + cassandraInitialized = true; + } + Logger.info({apply}, apply ? 'Backfilling donor email casing' : 'Inspecting donor email casing (dry run)'); + try { + const conflicted = await backfillDonors(apply); + await backfillDonorsByStripeCustomerId(apply, conflicted); + await backfillDonorsByStripeSubscriptionId(apply, conflicted); + await backfillMagicLinkTokens(apply, conflicted); + await backfillMagicLinkTokensByEmail(apply, conflicted); + if (conflicted.size > 0) { + Logger.warn({addresses: [...conflicted]}, 'Addresses left untouched, merge them by hand before a rerun'); + } + } finally { + if (cassandraInitialized) { + await shutdownCassandra(); + } + if (postgresInitialized) { + setDatabaseQueryExecutor(null); + await shutdownPostgres(); + } + } +} + +main().catch((error) => { + Logger.fatal({error}, 'Failed to backfill donor email casing'); + process.exit(1); +}); diff --git a/fluxer_api/src/api/Config.ts b/fluxer_api/src/api/Config.ts index d98d4ed9f..decff2619 100644 --- a/fluxer_api/src/api/Config.ts +++ b/fluxer_api/src/api/Config.ts @@ -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'); } @@ -275,6 +279,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), diff --git a/fluxer_api/src/api/config/APIConfig.ts b/fluxer_api/src/api/config/APIConfig.ts index ce48863b4..cb09a9b70 100644 --- a/fluxer_api/src/api/config/APIConfig.ts +++ b/fluxer_api/src/api/config/APIConfig.ts @@ -134,6 +134,7 @@ export interface APIConfig { internal: { gateway: string; gatewayRpcAuthToken: string; + donationProxyKey: string; }; hosts: { invite: string; diff --git a/fluxer_api/src/api/database/types/DonationTypes.ts b/fluxer_api/src/api/database/types/DonationTypes.ts index 88cbc0532..d19df9028 100644 --- a/fluxer_api/src/api/database/types/DonationTypes.ts +++ b/fluxer_api/src/api/database/types/DonationTypes.ts @@ -14,6 +14,7 @@ export interface DonorRow { subscription_interval: Nullish; subscription_current_period_end: Nullish; subscription_cancel_at: Nullish; + subscription_status: Nullish; 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', diff --git a/fluxer_api/src/api/donation/DonationController.ts b/fluxer_api/src/api/donation/DonationController.ts index e5e51a01b..49be54ce7 100644 --- a/fluxer_api/src/api/donation/DonationController.ts +++ b/fluxer_api/src/api/donation/DonationController.ts @@ -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( diff --git a/fluxer_api/src/api/donation/DonationRepository.ts b/fluxer_api/src/api/donation/DonationRepository.ts index b81b9b80b..f3beee337 100644 --- a/fluxer_api/src/api/donation/DonationRepository.ts +++ b/fluxer_api/src/api/donation/DonationRepository.ts @@ -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 { 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 { 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 { + await executeVersionedUpdate( + () => fetchOne(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 { + await upsertOne( + DonorsByStripeCustomerId.upsertAll({ + stripe_customer_id: stripeCustomerId, + email, + }), + ); + } + + async clearDonorStripeCustomer(stripeCustomerId: string): Promise { + const donor = await this.findDonorByStripeCustomerId(stripeCustomerId); + if (!donor) { + return; + } + if (donor.stripeCustomerId === stripeCustomerId) { + await executeVersionedUpdate( + () => fetchOne(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 { 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 { + await deleteOneOrMany( + DonorsByStripeSubscriptionId.deleteByPk({ + stripe_subscription_id: stripeSubscriptionId, + email, + }), + ); + } + async createMagicLinkToken(token: DonorMagicLinkToken): Promise { const row = token.toRow(); const batch = new BatchBuilder(); diff --git a/fluxer_api/src/api/donation/DonationService.ts b/fluxer_api/src/api/donation/DonationService.ts index 5bf3eab1a..7c72cedbf 100644 --- a/fluxer_api/src/api/donation/DonationService.ts +++ b/fluxer_api/src/api/donation/DonationService.ts @@ -22,6 +22,19 @@ export class DonationService implements IDonationService { return this.magicLinkService.validateToken(token); } + async redeemMagicLinkToken(token: string): Promise { + 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 { return this.checkoutService.createCheckout(params); } - - async createDonorPortalSession(stripeCustomerId: string): Promise { - return this.checkoutService.createPortalSession(stripeCustomerId); - } } diff --git a/fluxer_api/src/api/donation/IDonationRepository.ts b/fluxer_api/src/api/donation/IDonationRepository.ts index 81c0b0087..3b4d5f270 100644 --- a/fluxer_api/src/api/donation/IDonationRepository.ts +++ b/fluxer_api/src/api/donation/IDonationRepository.ts @@ -24,6 +24,7 @@ export abstract class IDonationRepository { subscriptionInterval: string | null; subscriptionCurrentPeriodEnd: Date | null; subscriptionCancelAt?: Date | null; + subscriptionStatus?: string | null; }): Promise; abstract updateDonorSubscription( @@ -39,11 +40,28 @@ export abstract class IDonationRepository { subscriptionInterval: string | null; subscriptionCurrentPeriodEnd: Date | null; subscriptionCancelAt?: Date | null; + subscriptionStatus?: string | null; }, ): Promise; + abstract updateDonorCustomerDetails( + email: string, + data: { + stripeCustomerId: string | null; + businessName?: string | null; + taxId?: string | null; + taxIdType?: string | null; + }, + ): Promise; + + abstract linkDonorStripeCustomer(email: string, stripeCustomerId: string): Promise; + + abstract clearDonorStripeCustomer(stripeCustomerId: string): Promise; + abstract cancelDonorSubscription(email: string): Promise; + abstract deleteDonorSubscriptionMapping(stripeSubscriptionId: string, email: string): Promise; + abstract createMagicLinkToken(token: DonorMagicLinkToken): Promise; abstract findMagicLinkToken(token: string): Promise; diff --git a/fluxer_api/src/api/donation/IDonationService.ts b/fluxer_api/src/api/donation/IDonationService.ts index b74fcc56a..9c1ee3e0e 100644 --- a/fluxer_api/src/api/donation/IDonationService.ts +++ b/fluxer_api/src/api/donation/IDonationService.ts @@ -8,6 +8,7 @@ export interface IDonationService { email: string; stripeCustomerId: string | null; }>; + redeemMagicLinkToken(token: string): Promise; createDonationCheckout(params: { email: string; amountCents: number; @@ -16,5 +17,4 @@ export interface IDonationService { isBusiness?: boolean; locale?: string | null; }): Promise; - createDonorPortalSession(stripeCustomerId: string): Promise; } diff --git a/fluxer_api/src/api/donation/models/Donor.ts b/fluxer_api/src/api/donation/models/Donor.ts index c1fd13368..8c207fc2e 100644 --- a/fluxer_api/src/api/donation/models/Donor.ts +++ b/fluxer_api/src/api/donation/models/Donor.ts @@ -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 = 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), diff --git a/fluxer_api/src/api/donation/services/DonationCheckoutService.ts b/fluxer_api/src/api/donation/services/DonationCheckoutService.ts index be0bf70eb..684ff5a95 100644 --- a/fluxer_api/src/api/donation/services/DonationCheckoutService.ts +++ b/fluxer_api/src/api/donation/services/DonationCheckoutService.ts @@ -18,9 +18,62 @@ import type Stripe from 'stripe'; type CheckoutSessionCreateParams = Stripe.Checkout.SessionCreateParams; type CheckoutSessionMode = CheckoutSessionCreateParams['mode']; type CheckoutSessionLineItem = NonNullable[number]; +type CheckoutSessionLocale = NonNullable; const PRODUCT_NAME = 'Fluxer'; +const STRIPE_CHECKOUT_LOCALES: Record = { + 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'}¤cy=${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 { + async createPortalSession(stripeCustomerId: string): Promise { 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 { + await this.donationRepository.clearDonorStripeCustomer(stripeCustomerId); + Logger.warn({stripeCustomerId}, 'Cleared deleted Stripe customer from donor'); + } } diff --git a/fluxer_api/src/api/donation/services/DonationMagicLinkService.ts b/fluxer_api/src/api/donation/services/DonationMagicLinkService.ts index 5053f99b6..560e61ce3 100644 --- a/fluxer_api/src/api/donation/services/DonationMagicLinkService.ts +++ b/fluxer_api/src/api/donation/services/DonationMagicLinkService.ts @@ -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 { + await this.donationRepository.markMagicLinkTokenUsed(token, new Date()); + } } diff --git a/fluxer_api/src/api/donation/tests/DonationCheckout.test.ts b/fluxer_api/src/api/donation/tests/DonationCheckout.test.ts index 0bb3b0be2..4aa24e843 100644 --- a/fluxer_api/src/api/donation/tests/DonationCheckout.test.ts +++ b/fluxer_api/src/api/donation/tests/DonationCheckout.test.ts @@ -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) diff --git a/fluxer_api/src/api/donation/tests/DonationManage.test.ts b/fluxer_api/src/api/donation/tests/DonationManage.test.ts index dbb279105..70bd39277 100644 --- a/fluxer_api/src/api/donation/tests/DonationManage.test.ts +++ b/fluxer_api/src/api/donation/tests/DonationManage.test.ts @@ -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 { + return createBuilderWithoutAuth(harness).post(`/donations/manage?token=${encodeURIComponent(token)}`); + } + async function openManageLink(token: string): Promise { + const {response} = await createDonationManageBuilder(harness, token).expect(302).executeWithResponse(); + return response.headers.get('location'); + } + async function redeemManageLink(token: string): Promise { + 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 { + 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 { 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 { - 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 { + 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 { - 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 { - 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 { + await createMagicLinkToken(email, token, new Date(Date.now() + 15 * 60 * 1000), null); + } + async function createExpiredMagicLinkToken(email: string, token: string): Promise { + await createMagicLinkToken(email, token, new Date(Date.now() - 1000), null); + } + async function createUsedMagicLinkToken(email: string, token: string): Promise { + 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')); }); }); }); diff --git a/fluxer_api/src/api/donation/tests/DonationRequestLink.test.ts b/fluxer_api/src/api/donation/tests/DonationRequestLink.test.ts index fb04d9d47..5a7908c37 100644 --- a/fluxer_api/src/api/donation/tests/DonationRequestLink.test.ts +++ b/fluxer_api/src/api/donation/tests/DonationRequestLink.test.ts @@ -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: ' test@example.com '}).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', () => { diff --git a/fluxer_api/src/api/donation/tests/DonationTestUtils.ts b/fluxer_api/src/api/donation/tests/DonationTestUtils.ts index 3791a5cb7..3ce61ad6b 100644 --- a/fluxer_api/src/api/donation/tests/DonationTestUtils.ts +++ b/fluxer_api/src/api/donation/tests/DonationTestUtils.ts @@ -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', diff --git a/fluxer_api/src/api/middleware/RateLimitMiddleware.ts b/fluxer_api/src/api/middleware/RateLimitMiddleware.ts index 1a0b80ef2..a4964ea12 100644 --- a/fluxer_api/src/api/middleware/RateLimitMiddleware.ts +++ b/fluxer_api/src/api/middleware/RateLimitMiddleware.ts @@ -1,13 +1,14 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {createHash} from 'node:crypto'; +import {createHash, timingSafeEqual} from 'node:crypto'; import * as AuthSession from '@app/api/auth/AuthSession'; import {Config} from '@app/api/Config'; import type {HonoEnv} from '@app/api/types/HonoEnv'; import {getRequestClientIp} from '@app/api/utils/RequestClientIp'; +import {readRequestJsonBody} from '@app/api/utils/RequestJsonBody'; import {UserFlags} from '@fluxer/constants/src/UserConstants'; import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError'; -import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress'; +import {getSameIpDecisionKey, parseIpAddress} from '@fluxer/ip_utils/src/IpAddress'; import type {BucketConfig, RateLimitResult, RateLimitScope} from '@pkgs/rate_limit/src/IRateLimitService'; import type {Context, MiddlewareHandler} from 'hono'; import {createMiddleware} from 'hono/factory'; @@ -18,10 +19,17 @@ export interface RouteRateLimitConfig { bucket: string; config: BucketConfig; scope?: RateLimitScope; + trustDonorIpHeader?: boolean; + emailBucket?: { + bucket: string; + config: BucketConfig; + }; } const TEST_ENABLE_RATE_LIMITS_HEADER = 'x-fluxer-test-enable-rate-limits'; const TEST_GLOBAL_RATE_LIMIT_OVERRIDE_HEADER = 'x-fluxer-test-global-rate-limit'; +const INTERNAL_KEY_HEADER = 'x-fluxer-internal-key'; +const DONOR_IP_HEADER = 'x-fluxer-donor-ip'; function shouldEnforceRateLimits(ctx: Context): boolean { if (!Config.dev.testModeEnabled) { @@ -49,7 +57,27 @@ function shouldShowHeadersOnSuccess(accountType: AccountType): boolean { return accountType === 'bot' || accountType === 'webhook'; } -function getClientIdentifier(ctx: Context): string { +function isTrustedInternalCaller(ctx: Context): boolean { + const expectedKey = Config.internal.donationProxyKey; + if (!expectedKey) return false; + const providedKey = ctx.req.header(INTERNAL_KEY_HEADER); + if (!providedKey) return false; + const expectedBuffer = Buffer.from(expectedKey); + const providedBuffer = Buffer.from(providedKey); + if (expectedBuffer.length !== providedBuffer.length) return false; + return timingSafeEqual(expectedBuffer, providedBuffer); +} + +function getForwardedDonorIdentifier(ctx: Context): string | null { + if (!isTrustedInternalCaller(ctx)) return null; + const headerValue = ctx.req.header(DONOR_IP_HEADER)?.split(',', 1)[0].trim(); + if (!headerValue) return null; + const donorIp = parseIpAddress(headerValue); + if (!donorIp) return null; + return `ip:${getSameIpDecisionKey(donorIp.normalized) ?? donorIp.normalized}`; +} + +function getClientIdentifier(ctx: Context, routeConfig: RouteRateLimitConfig): string { const user = ctx.get('user'); if (user?.id) { const tokenType = ctx.get('authTokenType') ?? 'session'; @@ -58,11 +86,25 @@ function getClientIdentifier(ctx: Context): string { } return `user:${user.id}:${tokenType}`; } + if (routeConfig.trustDonorIpHeader) { + const donorIdentifier = getForwardedDonorIdentifier(ctx); + if (donorIdentifier) return donorIdentifier; + } const ip = getRequestClientIp(ctx); if (!ip) return 'internal'; return `ip:${getSameIpDecisionKey(ip) ?? ip}`; } +async function getRequestEmailIdentifier(ctx: Context): Promise { + const body = await readRequestJsonBody(ctx.req); + if (!body.parsed || typeof body.value !== 'object' || body.value === null) return null; + const email = Reflect.get(body.value, 'email'); + if (typeof email !== 'string') return null; + const normalizedEmail = email.trim().toLowerCase(); + if (!normalizedEmail) return null; + return `email:${createHash('sha256').update(normalizedEmail).digest('hex').slice(0, 32)}`; +} + function getGlobalRateLimit(ctx: Context): number { if (Config.dev.testModeEnabled) { const override = ctx.req.header(TEST_GLOBAL_RATE_LIMIT_OVERRIDE_HEADER); @@ -131,6 +173,7 @@ async function revokeAuthenticatedSessionOnGlobalRateLimit(ctx: Context export function RateLimitMiddleware(routeConfig: RouteRateLimitConfig): MiddlewareHandler { const routeBucketHash = getBucketHash(routeConfig.bucket); + const emailBucketHash = routeConfig.emailBucket ? getBucketHash(routeConfig.emailBucket.bucket) : undefined; return createMiddleware(async (ctx, next) => { if (!shouldEnforceRateLimits(ctx)) { await next(); @@ -148,7 +191,7 @@ export function RateLimitMiddleware(routeConfig: RouteRateLimitConfig): Middlewa } const accountType = getAccountType(ctx); const showHeaders = shouldShowHeadersOnSuccess(accountType); - const clientId = getClientIdentifier(ctx); + const clientId = getClientIdentifier(ctx, routeConfig); if (!routeConfig.config.exemptFromGlobal) { const globalLimit = getGlobalRateLimit(ctx); const globalResult = await rateLimitService.checkGlobalLimit(clientId, globalLimit); @@ -182,6 +225,27 @@ export function RateLimitMiddleware(routeConfig: RouteRateLimitConfig): Middlewa scope: routeConfig.scope ?? 'user', }); } + const emailBucketConfig = routeConfig.emailBucket; + if (emailBucketConfig) { + const emailIdentifier = await getRequestEmailIdentifier(ctx); + if (emailIdentifier) { + const emailBucketResult = await rateLimitService.checkBucketLimit( + resolveBucket(emailBucketConfig.bucket, emailIdentifier, ctx), + {...emailBucketConfig.config, algorithm: 'leaky_bucket'}, + ); + if (!emailBucketResult.allowed) { + throw new RateLimitError({ + retryAfter: getRetryAfterSeconds(emailBucketResult), + retryAfterDecimal: emailBucketResult.retryAfterDecimal, + limit: emailBucketResult.limit, + resetTime: emailBucketResult.resetTime, + resetAfterDecimal: emailBucketResult.resetAfterDecimal, + bucketHash: emailBucketHash, + scope: 'shared', + }); + } + } + } if (showHeaders) { setRateLimitHeaders(ctx, bucketResult, routeBucketHash); } diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 37218660b..1c4b6b2e5 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -5310,7 +5310,7 @@ "/donations/manage": { "get": { "operationId": "manage_donation", - "summary": "Manage donation subscription", + "summary": "Open donation management link", "tags": ["Donations"], "responses": { "302": {"description": "Success"}, @@ -5345,7 +5345,60 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Validates the magic link token and redirects to Stripe billing portal.", + "description": "Checks the magic link token without consuming it and redirects to the donation management confirmation page.", + "parameters": [ + { + "name": "token", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "description": "Magic link token for donor authentication" + }, + "description": "Magic link token for donor authentication" + } + ] + }, + "post": { + "operationId": "redeem_donation_magic_link", + "summary": "Redeem donation management link", + "tags": ["Donations"], + "responses": { + "302": {"description": "Success"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Consumes the magic link token and redirects to the Stripe billing portal.", "parameters": [ { "name": "token", @@ -32004,7 +32057,7 @@ "required": ["id", "name"], "additionalProperties": false }, - "DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try"]}, + "DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try", "sek", "dkk", "nok"]}, "GuildExpressionSourceGuildResponse": { "type": "object", "properties": { diff --git a/fluxer_api/src/api/rate_limit_configs/DonationRateLimitConfig.ts b/fluxer_api/src/api/rate_limit_configs/DonationRateLimitConfig.ts index ebc2360f4..1c4fbe1cf 100644 --- a/fluxer_api/src/api/rate_limit_configs/DonationRateLimitConfig.ts +++ b/fluxer_api/src/api/rate_limit_configs/DonationRateLimitConfig.ts @@ -7,13 +7,24 @@ export const DonationRateLimitConfigs = { DONATION_REQUEST_LINK: { bucket: 'donation:request_link', config: {limit: 3, windowMs: ms('1 hour')}, + trustDonorIpHeader: true, + emailBucket: { + bucket: 'donation:request_link:email', + config: {limit: 10, windowMs: ms('1 hour')}, + }, } as RouteRateLimitConfig, DONATION_MANAGE: { bucket: 'donation:manage', config: {limit: 10, windowMs: ms('1 minute')}, + trustDonorIpHeader: true, } as RouteRateLimitConfig, DONATION_CHECKOUT: { bucket: 'donation:checkout', config: {limit: 5, windowMs: ms('1 minute')}, + trustDonorIpHeader: true, + emailBucket: { + bucket: 'donation:checkout:email', + config: {limit: 10, windowMs: ms('1 hour')}, + }, } as RouteRateLimitConfig, } as const; diff --git a/fluxer_api/src/api/stripe/services/StripeCheckoutWebhookHandler.ts b/fluxer_api/src/api/stripe/services/StripeCheckoutWebhookHandler.ts index b288c78da..777122431 100644 --- a/fluxer_api/src/api/stripe/services/StripeCheckoutWebhookHandler.ts +++ b/fluxer_api/src/api/stripe/services/StripeCheckoutWebhookHandler.ts @@ -31,9 +31,9 @@ import {seconds} from 'itty-time'; import type Stripe from 'stripe'; interface DonationCustomerDetails { - businessName: string | null; - taxId: string | null; - taxIdType: string | null; + businessName: string | undefined; + taxId: string | undefined; + taxIdType: string | undefined; } interface DonationSubscriptionDetails { @@ -42,6 +42,7 @@ interface DonationSubscriptionDetails { interval: string | null; currentPeriodEnd: Date | null; cancelAt: Date | null; + status: string | null; } interface CheckoutChargeDetails { @@ -112,6 +113,8 @@ export class StripeCheckoutWebhookHandler { async handleAsyncPaymentSucceeded(session: Stripe.Checkout.Session): Promise { if (session.metadata?.is_donation === 'true') { + Logger.info({sessionId: session.id}, 'Processing async payment succeeded for donation checkout session'); + await this.handleDonationCheckoutCompleted(session); return; } Logger.info({sessionId: session.id}, 'Processing async payment succeeded for checkout session'); @@ -120,6 +123,14 @@ export class StripeCheckoutWebhookHandler { async handleAsyncPaymentFailed(session: Stripe.Checkout.Session): Promise { if (session.metadata?.is_donation === 'true') { + Logger.warn( + { + sessionId: session.id, + email: session.metadata?.donation_email, + paymentStatus: session.payment_status, + }, + 'Async payment failed for donation checkout session; no confirmation sent', + ); return; } const payment = await this.userRepository.getPaymentByCheckoutSession(session.id); @@ -887,7 +898,7 @@ export class StripeCheckoutWebhookHandler { } private async handleDonationCheckoutCompleted(session: Stripe.Checkout.Session): Promise { - const email = session.metadata?.donation_email; + const email = session.metadata?.donation_email?.trim().toLowerCase(); if (!email) { Logger.error({sessionId: session.id}, 'Donation checkout missing email in metadata'); throw new StripeError('Donation checkout missing email'); @@ -904,24 +915,40 @@ export class StripeCheckoutWebhookHandler { Logger.error({sessionId: session.id}, 'Donation checkout missing subscription id'); throw new StripeError('Donation checkout missing subscription id'); } - const customerDetails = await this.loadDonationCustomerDetails(customerId); + const customerDetails = await this.loadDonationCustomerDetails( + customerId, + session.metadata?.is_business === 'true', + ); const subscriptionDetails = isRecurring ? await this.loadDonationSubscriptionDetails(subscriptionId) - : {amountCents: null, currency: null, interval: null, currentPeriodEnd: null, cancelAt: null}; + : {amountCents: null, currency: null, interval: null, currentPeriodEnd: null, cancelAt: null, status: null}; const existingDonor = await this.donationRepository.findDonorByEmail(email); - if (existingDonor) { + const subscriptionUpdate = { + stripeSubscriptionId: subscriptionId, + subscriptionAmountCents: subscriptionDetails.amountCents, + subscriptionCurrency: subscriptionDetails.currency, + subscriptionInterval: subscriptionDetails.interval, + subscriptionCurrentPeriodEnd: subscriptionDetails.currentPeriodEnd, + subscriptionCancelAt: subscriptionDetails.cancelAt, + subscriptionStatus: subscriptionDetails.status, + }; + if (existingDonor && isRecurring) { await this.donationRepository.updateDonorSubscription(email, { stripeCustomerId: customerId, businessName: customerDetails.businessName, taxId: customerDetails.taxId, taxIdType: customerDetails.taxIdType, - stripeSubscriptionId: subscriptionId, - subscriptionAmountCents: subscriptionDetails.amountCents, - subscriptionCurrency: subscriptionDetails.currency, - subscriptionInterval: subscriptionDetails.interval, - subscriptionCurrentPeriodEnd: subscriptionDetails.currentPeriodEnd, - subscriptionCancelAt: subscriptionDetails.cancelAt, + ...subscriptionUpdate, }); + } else if (existingDonor) { + const subscriptionCustomerId = existingDonor.stripeSubscriptionId ? existingDonor.stripeCustomerId : null; + await this.donationRepository.updateDonorCustomerDetails(email, { + stripeCustomerId: subscriptionCustomerId ?? customerId, + businessName: customerDetails.businessName, + taxId: customerDetails.taxId, + taxIdType: customerDetails.taxIdType, + }); + await this.donationRepository.linkDonorStripeCustomer(email, customerId); } else { await this.donationRepository.createDonor({ email, @@ -929,14 +956,16 @@ export class StripeCheckoutWebhookHandler { businessName: customerDetails.businessName, taxId: customerDetails.taxId, taxIdType: customerDetails.taxIdType, - stripeSubscriptionId: subscriptionId, - subscriptionAmountCents: subscriptionDetails.amountCents, - subscriptionCurrency: subscriptionDetails.currency, - subscriptionInterval: subscriptionDetails.interval, - subscriptionCurrentPeriodEnd: subscriptionDetails.currentPeriodEnd, - subscriptionCancelAt: subscriptionDetails.cancelAt, + ...subscriptionUpdate, }); } + if (session.payment_status === 'unpaid') { + Logger.info( + {sessionId: session.id, email, isRecurring, paymentStatus: session.payment_status}, + 'Donation checkout completed with unpaid status; deferring confirmation until settlement', + ); + return; + } const encodedEmail = encodeURIComponent(email); const manageUrl = `${Config.endpoints.marketing}/donate/manage?email=${encodedEmail}`; if (isRecurring) { @@ -947,14 +976,15 @@ export class StripeCheckoutWebhookHandler { Logger.error({sessionId: session.id, subscriptionId}, 'Donation subscription details incomplete'); throw new StripeError('Donation subscription details incomplete'); } - await this.emailService.sendDonationConfirmation( + await this.sendDonationConfirmationOnce({ + sessionId: session.id, email, - recurringAmountCents, - recurringCurrency, - recurringInterval, + amountCents: recurringAmountCents, + currency: recurringCurrency, + interval: recurringInterval, manageUrl, - donationLocale, - ); + locale: donationLocale, + }); } else { const oneTimeAmountCents = session.amount_total; const oneTimeCurrency = session.currency; @@ -965,14 +995,15 @@ export class StripeCheckoutWebhookHandler { ); throw new StripeError('Donation checkout missing amount or currency'); } - await this.emailService.sendDonationConfirmation( + await this.sendDonationConfirmationOnce({ + sessionId: session.id, email, - oneTimeAmountCents, - oneTimeCurrency, - 'once', + amountCents: oneTimeAmountCents, + currency: oneTimeCurrency, + interval: 'once', manageUrl, - donationLocale, - ); + locale: donationLocale, + }); } Logger.info( { @@ -991,6 +1022,47 @@ export class StripeCheckoutWebhookHandler { ); } + private async sendDonationConfirmationOnce({ + sessionId, + email, + amountCents, + currency, + interval, + manageUrl, + locale, + }: { + sessionId: string; + email: string; + amountCents: number; + currency: string; + interval: string; + manageUrl: string; + locale: string | null; + }): Promise { + const confirmationSentKey = this.getDonationConfirmationSentKey(sessionId); + if (await this.cacheService.get(confirmationSentKey)) { + Logger.debug({sessionId}, 'Donation confirmation already sent for checkout session'); + return; + } + const sent = await this.emailService.sendDonationConfirmation( + email, + amountCents, + currency, + interval, + manageUrl, + locale, + ); + if (!sent) { + Logger.error({sessionId, email, interval}, 'Failed to send donation confirmation email'); + throw new StripeError('Failed to send donation confirmation email'); + } + await this.cacheService.set( + confirmationSentKey, + true, + StripeCheckoutWebhookHandler.CHECKOUT_EFFECTS_APPLIED_TTL_SECONDS, + ); + } + private async cancelStripeSubscriptionImmediately(user: User): Promise { if (!this.stripe) { throw new StripeError('Stripe client not available for immediate cancellation'); @@ -1185,19 +1257,23 @@ export class StripeCheckoutWebhookHandler { return `stripe:checkout:gift:finalised:${checkoutSessionId}`; } - private async loadDonationCustomerDetails(customerId: string): Promise { + private getDonationConfirmationSentKey(checkoutSessionId: string): string { + return `stripe:checkout:donation:confirmation:sent:${checkoutSessionId}`; + } + + private async loadDonationCustomerDetails(customerId: string, isBusiness: boolean): Promise { if (!this.stripe) { throw new StripeError('Stripe client not available for donation customer lookup'); } try { - const customer = await this.stripe.customers.retrieve(customerId); + const customer = await this.stripe.customers.retrieve(customerId, {expand: ['tax_ids']}); if (customer && !customer.deleted) { - const businessName = customer.name ?? null; const primaryTaxId = customer.tax_ids?.data?.[0] ?? null; + const isBusinessDonor = isBusiness || primaryTaxId !== null; return { - businessName, - taxId: primaryTaxId?.value ?? null, - taxIdType: primaryTaxId?.type ?? null, + businessName: isBusinessDonor ? (customer.name ?? undefined) : undefined, + taxId: primaryTaxId?.value ?? undefined, + taxIdType: primaryTaxId?.type ?? undefined, }; } } catch (error) { @@ -1237,6 +1313,7 @@ export class StripeCheckoutWebhookHandler { interval: item.price.recurring.interval, currentPeriodEnd, cancelAt, + status: subscription.status, }; } catch (error) { Logger.error({error, subscriptionId}, 'Failed to retrieve subscription details'); diff --git a/fluxer_api/src/api/stripe/services/StripeDisputeWebhookHandler.ts b/fluxer_api/src/api/stripe/services/StripeDisputeWebhookHandler.ts index 2904da287..cf1cb7f7e 100644 --- a/fluxer_api/src/api/stripe/services/StripeDisputeWebhookHandler.ts +++ b/fluxer_api/src/api/stripe/services/StripeDisputeWebhookHandler.ts @@ -2,6 +2,7 @@ import type {UserRow} from '@app/api/database/types/UserTypes'; import type {IDonationRepository} from '@app/api/donation/IDonationRepository'; +import type {Donor} from '@app/api/donation/models/Donor'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAccountDeletionQueueService'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; @@ -49,6 +50,14 @@ export class StripeDisputeWebhookHandler { } const payment = await this.userRepository.getPaymentByPaymentIntent(paymentIntentId); if (!payment) { + const donor = await this.findDonorForDispute(dispute); + if (donor) { + Logger.info( + {paymentIntentId, disputeId: dispute.id, email: donor.email}, + 'Chargeback for donation customer - no premium action required', + ); + return; + } Logger.error({paymentIntentId}, 'No payment found for chargeback'); throw new StripeError('No payment found for chargeback'); } @@ -73,6 +82,14 @@ export class StripeDisputeWebhookHandler { } const payment = await this.userRepository.getPaymentByPaymentIntent(paymentIntentId); if (!payment) { + const donor = await this.findDonorForDispute(dispute); + if (donor) { + Logger.info( + {paymentIntentId, disputeId: dispute.id, email: donor.email}, + 'Chargeback withdrawal for donation customer - no premium action required', + ); + return; + } throw new StripeError('No payment found for chargeback withdrawal'); } const user = await this.userRepository.findUnique(payment.userId); @@ -110,6 +127,30 @@ export class StripeDisputeWebhookHandler { } } + private async resolveDisputeCustomerId(dispute: Stripe.Dispute): Promise { + const expandedCharge = typeof dispute.charge === 'object' ? dispute.charge : null; + if (expandedCharge) { + const expandedCustomerId = extractId(expandedCharge.customer); + if (expandedCustomerId) { + return expandedCustomerId; + } + } + const chargeId = extractId(dispute.charge); + if (!chargeId) { + return null; + } + const mirroredCharge = await getBillingRepository().charges.findById(chargeId); + return mirroredCharge?.customer_id ?? null; + } + + private async findDonorForDispute(dispute: Stripe.Dispute): Promise { + const customerId = await this.resolveDisputeCustomerId(dispute); + if (!customerId) { + return null; + } + return await this.donationRepository.findDonorByStripeCustomerId(customerId); + } + async handleRefund(charge: Stripe.Charge): Promise { const paymentIntentId = extractId(charge.payment_intent); if (!paymentIntentId) { diff --git a/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts b/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts index 43e321593..7b46f6906 100644 --- a/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts +++ b/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts @@ -2,10 +2,9 @@ import type {UserID} from '@app/api/BrandedTypes'; import type {BillingRepository} from '@app/api/billing/repositories/BillingRepository'; -import {nextVersion} from '@app/api/database/CassandraTypes'; import type {UserRow} from '@app/api/database/types/UserTypes'; import type {IDonationRepository} from '@app/api/donation/IDonationRepository'; -import {Donor} from '@app/api/donation/models/Donor'; +import type {Donor} from '@app/api/donation/models/Donor'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {PremiumStateReconciliationQueueService} from '@app/api/infrastructure/PremiumStateReconciliationQueueService'; import {Logger} from '@app/api/Logger'; @@ -36,6 +35,19 @@ import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError'; import type Stripe from 'stripe'; +const TERMINAL_SUBSCRIPTION_STATUSES: ReadonlySet = new Set([ + 'canceled', + 'incomplete_expired', +]); + +function isDonationSubscription(subscription: Stripe.Subscription): boolean { + return subscription.metadata?.is_donation === 'true'; +} + +function isDonationInvoice(invoice: Stripe.Invoice): boolean { + return invoice.parent?.subscription_details?.metadata?.is_donation === 'true'; +} + export class StripeSubscriptionWebhookHandler { private readonly paymentRepository = new PaymentRepository(); @@ -73,6 +85,13 @@ export class StripeSubscriptionWebhookHandler { ); return; } + if (isDonationInvoice(invoice)) { + Logger.debug( + {invoiceId: invoice.id, eventId, subscriptionId}, + 'Skipping invoice payment for donation subscription that has no donor mapping', + ); + return; + } if (this.isSubscriptionUpdateInvoice(invoice)) { Logger.debug( { @@ -301,6 +320,17 @@ export class StripeSubscriptionWebhookHandler { }); } + private async isDonationSubscriptionEvent(subscription: Stripe.Subscription): Promise { + if (isDonationSubscription(subscription)) { + return true; + } + const customerId = extractId(subscription.customer); + if (!customerId) { + return false; + } + return (await this.donationRepository.findDonorByStripeCustomerId(customerId)) !== null; + } + private async handleInvoiceCollectionIssue( invoice: Stripe.Invoice, context: { @@ -309,6 +339,17 @@ export class StripeSubscriptionWebhookHandler { }, ): Promise { const subscriptionId = this.reconciler.getSubscriptionIdFromInvoice(invoice); + if (isDonationInvoice(invoice)) { + Logger.debug({invoiceId: invoice.id, subscriptionId}, 'Skipping collection issue for donation invoice'); + return; + } + if (subscriptionId && (await this.donationRepository.findDonorByStripeSubscriptionId(subscriptionId))) { + Logger.debug( + {invoiceId: invoice.id, subscriptionId}, + 'Skipping collection issue for donation subscription invoice', + ); + return; + } const targetUser = await this.reconciler.resolveUserForInvoiceReconciliation(invoice, { subscriptionId, reason: context.reason, @@ -371,6 +412,13 @@ export class StripeSubscriptionWebhookHandler { await this.handleDonationSubscriptionUpdated(subscription, donor); return; } + if (await this.isDonationSubscriptionEvent(subscription)) { + Logger.debug( + {subscriptionId: subscription.id, status: subscription.status}, + 'Skipping donation subscription update that has no donor mapping yet', + ); + return; + } let canonicalSubscription = subscription; const subscriptionInfo = await this.userRepository.getSubscriptionInfo(subscription.id); Logger.debug( @@ -548,19 +596,27 @@ export class StripeSubscriptionWebhookHandler { async handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise { const donor = await this.donationRepository.findDonorByStripeSubscriptionId(subscription.id); if (donor) { - const updatedDonor = new Donor({ - ...donor.toRow(), - stripe_subscription_id: null, - subscription_amount_cents: null, - subscription_currency: null, - subscription_interval: null, - subscription_current_period_end: null, - subscription_cancel_at: null, - updated_at: new Date(), - version: nextVersion(donor.version), - }); - await this.donationRepository.upsertDonor(updatedDonor); - Logger.info({email: donor.email, subscriptionId: subscription.id}, 'Donation subscription deleted'); + if (donor.stripeSubscriptionId === subscription.id) { + await this.donationRepository.cancelDonorSubscription(donor.email); + Logger.info({email: donor.email, subscriptionId: subscription.id}, 'Donation subscription deleted'); + return; + } + await this.donationRepository.deleteDonorSubscriptionMapping(subscription.id, donor.email); + Logger.info( + { + email: donor.email, + subscriptionId: subscription.id, + currentSubscriptionId: donor.stripeSubscriptionId, + }, + 'Removed stale donation subscription mapping without touching the current donation', + ); + return; + } + if (await this.isDonationSubscriptionEvent(subscription)) { + Logger.debug( + {subscriptionId: subscription.id}, + 'Skipping donation subscription deletion that has no donor mapping', + ); return; } const info = await this.userRepository.getSubscriptionInfo(subscription.id); @@ -621,6 +677,30 @@ export class StripeSubscriptionWebhookHandler { } private async handleDonationSubscriptionUpdated(subscription: Stripe.Subscription, donor: Donor): Promise { + const isTerminal = TERMINAL_SUBSCRIPTION_STATUSES.has(subscription.status); + if (donor.stripeSubscriptionId !== subscription.id) { + if (isTerminal) { + await this.donationRepository.deleteDonorSubscriptionMapping(subscription.id, donor.email); + } + Logger.info( + { + email: donor.email, + subscriptionId: subscription.id, + currentSubscriptionId: donor.stripeSubscriptionId, + status: subscription.status, + }, + 'Ignoring donation subscription update for a subscription the donor no longer tracks', + ); + return; + } + if (isTerminal) { + await this.donationRepository.cancelDonorSubscription(donor.email); + Logger.info( + {email: donor.email, subscriptionId: subscription.id, status: subscription.status}, + 'Donation subscription reached a terminal status', + ); + return; + } const item = getPrimarySubscriptionItem(subscription); if (!item?.price?.recurring || item.price.unit_amount == null || !item.price.currency) { Logger.error({subscriptionId: subscription.id}, 'Donation subscription update missing pricing details'); @@ -636,13 +716,14 @@ export class StripeSubscriptionWebhookHandler { const interval = item.price.recurring.interval; const cancelAt = subscription.cancel_at ? new Date(subscription.cancel_at * 1000) : null; await this.donationRepository.updateDonorSubscription(donor.email, { - stripeCustomerId: donor.stripeCustomerId, + stripeCustomerId: extractId(subscription.customer) ?? donor.stripeCustomerId, stripeSubscriptionId: subscription.id, subscriptionAmountCents: amountCents, subscriptionCurrency: currency, subscriptionInterval: interval, subscriptionCurrentPeriodEnd: currentPeriodEnd, subscriptionCancelAt: cancelAt, + subscriptionStatus: subscription.status, }); Logger.debug( { diff --git a/fluxer_api/src/api/stripe/services/StripeWebhookService.ts b/fluxer_api/src/api/stripe/services/StripeWebhookService.ts index 906694f18..fb9fc0b18 100644 --- a/fluxer_api/src/api/stripe/services/StripeWebhookService.ts +++ b/fluxer_api/src/api/stripe/services/StripeWebhookService.ts @@ -55,7 +55,7 @@ export class StripeWebhookService { cacheService: ICacheService, giftService: StripeGiftService, premiumService: StripePremiumService, - donationRepository: IDonationRepository, + private donationRepository: IDonationRepository, kvDeletionQueue: KVAccountDeletionQueueService, premiumStateReconciliationQueueService: PremiumStateReconciliationQueueService, private ageVerificationService: AgeVerificationService | null, @@ -281,6 +281,7 @@ export class StripeWebhookService { const cust = event.data.object as Stripe.Customer | Stripe.DeletedCustomer; await this.safeMirrorUpsert(event, () => this.billingRepository.customers.upsertFromStripe(cust)); await this.safeMirrorUpsert(event, () => this.billingRepository.customers.markDeleted(cust.id, new Date())); + await this.donationRepository.clearDonorStripeCustomer(cust.id); break; } case 'product.created': diff --git a/fluxer_docs/scripts/VerifyDocsStyle.ts b/fluxer_docs/scripts/VerifyDocsStyle.ts index ca4a2910b..a0f5e3d80 100644 --- a/fluxer_docs/scripts/VerifyDocsStyle.ts +++ b/fluxer_docs/scripts/VerifyDocsStyle.ts @@ -288,7 +288,7 @@ const ACCEPTED_TABLE_FINDINGS = new Map1 An empty string is reported as a missing address -2 A padded address fails syntax validation +2 A padded address has its surrounding whitespace removed before validation 3 The domain must publish an MX, A or AAAA record @@ -104,36 +107,63 @@ Fluxer creates a single-use token of 64 lowercase hexadecimal characters, valid 3 requests per hour for each client IP address or authenticated user, on the `donation:request_link` bucket. +10 requests per hour for each address, on the shared `donation:request_link:email` bucket. + ## Manage donation -Consumes a donation management token. Answers 302 with `Location` set to the externally hosted billing portal for the matching donor. The token is the credential. +Checks a donation management token and leaves it unspent. Answers 302 with `Location` set to the donation management confirmation page on the public site. The token is the credential. -An unknown token returns 400 `DONATION_MAGIC_LINK_INVALID`, an expired token returns `DONATION_MAGIC_LINK_EXPIRED`, and a used token returns `DONATION_MAGIC_LINK_USED`. An expired and used token reports `DONATION_MAGIC_LINK_EXPIRED`. - -A token whose address no longer resolves to a donor holding a payment provider customer is still consumed, and the redirect goes to the public donation page. For a donor holding a customer, a deployment with no configured payment provider returns 400 `STRIPE_PAYMENT_NOT_AVAILABLE` and a provider failure returns 400 `STRIPE_ERROR`, both after the token has already been consumed. +An unknown token redirects with `link_invalid` in `alert`, an expired token with `link_expired`, and a used token with `link_used`. An expired and used token reports `link_expired`. A token whose address no longer resolves to a donor holding a payment provider customer redirects with `no_customer`. ### Query parameters | Field | Type | Description | | --- | --- | --- | -| token1 2 | string | The management token, exactly 64 characters | +| token1 | string | The management token, exactly 64 characters | -1 A token whose case was changed in transit returns 400 `DONATION_MAGIC_LINK_INVALID` - -2 Consumed as soon as it is found valid, so a portal creation failure leaves the token spent +1 A token whose case was changed in transit redirects with `link_invalid` ### Response | Status | Body | Condition | | --- | --- | --- | -| 302 | empty | The token was consumed, and `Location` is either the billing portal session or the public donation page | -| 400 | [error response](/http-api/#error-response) | The token is unknown, expired or already consumed, the payment provider is not configured, or the payment provider rejected the portal creation | +| 302 | empty | `Location` is the confirmation page, or the management page with an `alert` value | +| 400 | [error response](/http-api/#error-response) | The token is not exactly 64 characters | ### Side effects -The token is consumed. A resolved donor holding a payment provider customer receives a portal session. Closing that session returns the donor to the public donation page. Where the address resolves to no donor, or to a donor with no customer, Fluxer redirects to the public donation page and creates no portal session. +None. The token stays spendable, so a mail scanner that opens the link first does not spend it. + +### Rate limit + +10 requests per minute for each client IP address or authenticated user, on the `donation:manage` bucket. + +## Redeem donation management link + + + +Creates the billing portal session and then consumes the token. Answers 302 with `Location` set to the externally hosted billing portal. The token is consumed only after the portal session exists, so a payment provider failure leaves it spendable. + +Every other outcome redirects to the public donation management page with `alert` set to `link_invalid`, `link_expired`, `link_used`, `no_customer` or `portal_error`. A deployment with no configured payment provider redirects with `portal_error`, and so does a provider that rejects the portal creation. + +### Query parameters + +| Field | Type | Description | +| --- | --- | --- | +| token | string | The management token, exactly 64 characters | + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 302 | empty | `Location` is the billing portal session, or the management page with an `alert` value | +| 400 | [error response](/http-api/#error-response) | The token is not exactly 64 characters | + +### Side effects + +The token is consumed. Closing the portal session returns the donor to the public donation page. ### Rate limit @@ -149,6 +179,8 @@ An amount outside the selected currency's bounds fails validation with 400 `INVA A recurring donation for an address with an active recurring donation returns the public donation management page, with the percent-encoded address in `email` and `active_subscription` in `alert`. A donation scheduled for cancellation does not block a new checkout. +The session is bound to the address by email alone, never to a payment provider customer the address already has. Addresses are trimmed and lowercased before every lookup and before they are stored. + ### JSON body | Field | Type | Description | @@ -181,3 +213,5 @@ When the payment provider confirms the payment, Fluxer stores the address as a d ### Rate limit 5 requests per minute for each client IP address or authenticated user, on the `donation:checkout` bucket. + +10 requests per hour for each address, on the shared `donation:checkout:email` bucket. diff --git a/fluxer_docs/src/content/docs/operator/configuration.mdx b/fluxer_docs/src/content/docs/operator/configuration.mdx index b7b61554c..c617381e6 100644 --- a/fluxer_docs/src/content/docs/operator/configuration.mdx +++ b/fluxer_docs/src/content/docs/operator/configuration.mdx @@ -309,6 +309,10 @@ Default empty. Addresses exempt from IP bans. Comma separated. Every entry must The API returns 403 for any request whose client-IP header is missing, empty, or not a parsable address, and for every request while `FLUXER_TRUST_CLIENT_IP_HEADER` is `false`. The exceptions are `/_health`, `/webhooks/livekit`, `/test`, and the Bluesky client metadata and JWKS routes. The edge sets the header on every upstream hop, so a missing or unparsable header happens only in a layout that puts something other than the edge directly in front of `api`. A proxy in front of the edge that never sets the header passes the check, and every request then looks as though it came from the proxy. +#### `FLUXER_API_DONATION_PROXY_KEY` + +Default empty, which keys donation rate limits on the caller. Set it to let a trusted front end, such as the marketing site, send the donor address the limits apply to. The key must be at least 32 characters or the API fails at boot. The front end sends the same value in `X-Fluxer-Internal-Key` and the donor address in `X-Fluxer-Donor-Ip`. The API ignores the address header unless the key matches, so a browser cannot set it. Self-hosters leave this empty. + ## Images These pick which container images Compose pulls. All are optional. diff --git a/packages/config/src/ConfigLoader.ts b/packages/config/src/ConfigLoader.ts index 4f145bb89..0c98a9a0e 100644 --- a/packages/config/src/ConfigLoader.ts +++ b/packages/config/src/ConfigLoader.ts @@ -104,6 +104,7 @@ function defaultConfig(): MasterConfig { request_timeout_ms: 120_000, max_inflight_requests: 512, ip_ban_exempt_ips: [], + donation_proxy_key: '', desktop_github_redirect_countries: [], presigned_attachment_uploads_enabled: false, presigned_downloads_enabled: false, diff --git a/packages/config/src/MasterConfig.ts b/packages/config/src/MasterConfig.ts index 7f70da0df..9a6a9db3b 100644 --- a/packages/config/src/MasterConfig.ts +++ b/packages/config/src/MasterConfig.ts @@ -97,6 +97,7 @@ export interface MasterConfig { request_timeout_ms: number; max_inflight_requests: number; ip_ban_exempt_ips: Array; + donation_proxy_key: string; desktop_github_redirect_countries: Array; presigned_attachment_uploads_enabled: boolean; presigned_downloads_enabled: boolean; diff --git a/packages/config/src/config_loader/EnvironmentOverrides.ts b/packages/config/src/config_loader/EnvironmentOverrides.ts index 320562017..044f7f2bc 100644 --- a/packages/config/src/config_loader/EnvironmentOverrides.ts +++ b/packages/config/src/config_loader/EnvironmentOverrides.ts @@ -84,6 +84,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { FLUXER_API_REQUEST_TIMEOUT_MS: {path: ['services', 'api', 'request_timeout_ms'], parse: parseInteger}, FLUXER_API_MAX_INFLIGHT_REQUESTS: {path: ['services', 'api', 'max_inflight_requests'], parse: parseInteger}, FLUXER_API_IP_BAN_EXEMPT_IPS: {path: ['services', 'api', 'ip_ban_exempt_ips'], parse: parseCsv}, + FLUXER_API_DONATION_PROXY_KEY: {path: ['services', 'api', 'donation_proxy_key']}, FLUXER_API_DESKTOP_GITHUB_REDIRECT_COUNTRIES: { path: ['services', 'api', 'desktop_github_redirect_countries'], parse: parseCsv, diff --git a/packages/schema/src/domains/donation/DonationAmountUtils.ts b/packages/schema/src/domains/donation/DonationAmountUtils.ts index 46d207638..daa199ab3 100644 --- a/packages/schema/src/domains/donation/DonationAmountUtils.ts +++ b/packages/schema/src/domains/donation/DonationAmountUtils.ts @@ -1,60 +1,26 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -export const DONATION_CURRENCIES = ['usd', 'eur', 'brl', 'inr', 'pln', 'try'] as const; +export const DONATION_CURRENCIES = ['usd', 'eur', 'brl', 'inr', 'pln', 'try', 'sek', 'dkk', 'nok'] as const; export type DonationCurrency = (typeof DONATION_CURRENCIES)[number]; interface DonationAmountConstraints { - displayCurrency: 'USD' | 'EUR' | 'BRL' | 'INR' | 'PLN' | 'TRY'; minimumAmountMinor: number; maximumAmountMinor: number; - presetAmountsMajor: readonly [number, number, number, number, number]; - defaultPresetIndex: 0 | 1 | 2 | 3 | 4; } +const STRIPE_MAXIMUM_AMOUNT_MINOR = 99_999_999; + const DONATION_AMOUNT_CONSTRAINTS: Readonly> = { - usd: { - displayCurrency: 'USD', - minimumAmountMinor: 500, - maximumAmountMinor: 100000, - presetAmountsMajor: [5, 25, 50, 100, 500], - defaultPresetIndex: 1, - }, - eur: { - displayCurrency: 'EUR', - minimumAmountMinor: 500, - maximumAmountMinor: 100000, - presetAmountsMajor: [5, 25, 50, 100, 500], - defaultPresetIndex: 1, - }, - brl: { - displayCurrency: 'BRL', - minimumAmountMinor: 2500, - maximumAmountMinor: 500000, - presetAmountsMajor: [25, 50, 100, 250, 500], - defaultPresetIndex: 1, - }, - inr: { - displayCurrency: 'INR', - minimumAmountMinor: 50000, - maximumAmountMinor: 10000000, - presetAmountsMajor: [500, 1000, 2500, 5000, 10000], - defaultPresetIndex: 1, - }, - pln: { - displayCurrency: 'PLN', - minimumAmountMinor: 2000, - maximumAmountMinor: 400000, - presetAmountsMajor: [20, 50, 100, 250, 500], - defaultPresetIndex: 1, - }, - try: { - displayCurrency: 'TRY', - minimumAmountMinor: 25000, - maximumAmountMinor: 5000000, - presetAmountsMajor: [250, 500, 1000, 2500, 5000], - defaultPresetIndex: 1, - }, + usd: {minimumAmountMinor: 300, maximumAmountMinor: STRIPE_MAXIMUM_AMOUNT_MINOR}, + eur: {minimumAmountMinor: 300, maximumAmountMinor: STRIPE_MAXIMUM_AMOUNT_MINOR}, + brl: {minimumAmountMinor: 1000, maximumAmountMinor: STRIPE_MAXIMUM_AMOUNT_MINOR}, + inr: {minimumAmountMinor: 20000, maximumAmountMinor: STRIPE_MAXIMUM_AMOUNT_MINOR}, + pln: {minimumAmountMinor: 800, maximumAmountMinor: STRIPE_MAXIMUM_AMOUNT_MINOR}, + try: {minimumAmountMinor: 10000, maximumAmountMinor: STRIPE_MAXIMUM_AMOUNT_MINOR}, + sek: {minimumAmountMinor: 3000, maximumAmountMinor: STRIPE_MAXIMUM_AMOUNT_MINOR}, + dkk: {minimumAmountMinor: 2000, maximumAmountMinor: STRIPE_MAXIMUM_AMOUNT_MINOR}, + nok: {minimumAmountMinor: 3000, maximumAmountMinor: STRIPE_MAXIMUM_AMOUNT_MINOR}, }; export function getDonationAmountConstraints(currency: DonationCurrency): DonationAmountConstraints { diff --git a/packages/schema/src/domains/donation/DonationSchemas.ts b/packages/schema/src/domains/donation/DonationSchemas.ts index 8826c0b68..d7c21e2e6 100644 --- a/packages/schema/src/domains/donation/DonationSchemas.ts +++ b/packages/schema/src/domains/donation/DonationSchemas.ts @@ -6,8 +6,10 @@ import { } from '@fluxer/schema/src/domains/donation/DonationAmountUtils'; import {z} from 'zod'; +const DonorEmail = z.string().trim().toLowerCase().check(z.email()).max(254); + export const DonationRequestLinkRequest = z.object({ - email: z.email().max(254).describe('Email address to send the magic link to'), + email: DonorEmail.describe('Email address to send the magic link to'), }); export type DonationRequestLinkRequest = z.infer; @@ -24,7 +26,7 @@ export type DonationCurrency = z.infer; export const DonationCheckoutRequest = z .object({ - email: z.email().max(254).describe('Donor email address'), + email: DonorEmail.describe('Donor email address'), amount_cents: z.number().int().describe('Donation amount in minor units for the selected currency'), currency: DonationCurrency.describe('Currency for the donation'), interval: z.enum(['month', 'year']).nullable().describe('Billing interval (null for one-time donation)'), diff --git a/tools/dev/cassandra_target_schema.json b/tools/dev/cassandra_target_schema.json index f51a4e6f3..4d22c4a1e 100644 --- a/tools/dev/cassandra_target_schema.json +++ b/tools/dev/cassandra_target_schema.json @@ -3744,6 +3744,10 @@ "name": "subscription_cancel_at", "type": "timestamp" }, + { + "name": "subscription_status", + "type": "text" + }, { "name": "created_at", "type": "timestamp"