mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(donations): add Nordic currencies, raise amount ceilings (#2775)
This commit is contained in:
@@ -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:",
|
||||
|
||||
@@ -9,6 +9,13 @@ import type {IEmailService} from '@pkgs/email/src/IEmailService';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
const logger = createLogger('email-service');
|
||||
const DEFAULT_LOCALE = 'en-US';
|
||||
|
||||
function formatMinorUnitAmount(amountMinor: number, currency: string, locale: string | null): string {
|
||||
const formatter = new Intl.NumberFormat(locale || DEFAULT_LOCALE, {style: 'currency', currency});
|
||||
const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2;
|
||||
return formatter.format(amountMinor / 10 ** fractionDigits);
|
||||
}
|
||||
|
||||
export class EmailService implements IEmailService {
|
||||
private readonly config: EmailConfig;
|
||||
@@ -301,7 +308,7 @@ export class EmailService implements IEmailService {
|
||||
locale: string | null = null,
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplatedEmail(email, 'donation_confirmation', locale, {
|
||||
amount: (amountCents / 100).toFixed(2),
|
||||
amount: formatMinorUnitAmount(amountCents, currency, locale),
|
||||
currency: currency.toUpperCase(),
|
||||
interval,
|
||||
manageUrl,
|
||||
|
||||
@@ -17,7 +17,7 @@ export const EMAIL_I18N_MESSAGES = {
|
||||
},
|
||||
donation_confirmation: {
|
||||
subject: 'Thank you for your {product_name} donation',
|
||||
body: 'Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {currency} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team',
|
||||
body: 'Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team',
|
||||
},
|
||||
donation_magic_link: {
|
||||
subject: 'Manage your {product_name} donations',
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_AR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "شكرًا لك على تبرعك لـ{product_name}",
|
||||
"body": "مرحبًا،\n\nشكرًا لك على تبرعك لـ{product_name}! لقد تم {interval, select,\n month {إعداد تبرعك المتكرر}\n year {إعداد تبرعك المتكرر}\n other {معالجة تبرعك لمرة واحدة}\n} بنجاح.\n\nتفاصيل التبرع:\nالمبلغ: {amount} {currency} {interval, select,\n month {شهريًا}\n year {سنويًا}\n other {}\n}\n\nسترسل لك Stripe إيصالًا منفصلًا عبر البريد الإلكتروني مع فاتورتك بصيغة PDF قريبًا. يتضمن هذا جميع تفاصيل الدفع ويمكن استخدامه لأغراض ضريبية.\n\nيمكنك عرض سجل تبرعاتك، وتنزيل الفواتير، {interval, select,\n month {وإدارة أو إلغاء اشتراكك}\n year {وإدارة أو إلغاء اشتراكك}\n other {وإدارة التبرعات المستقبلية}\n} في أي وقت باستخدام هذا الرابط:\n\n{manageUrl}\n\nدعمك يساعد على استمرار {product_name}. شكرًا لك!\n\n– فريق {product_name}"
|
||||
"body": "مرحبًا،\n\nشكرًا لك على تبرعك لـ{product_name}! لقد تم {interval, select,\n month {إعداد تبرعك المتكرر}\n year {إعداد تبرعك المتكرر}\n other {معالجة تبرعك لمرة واحدة}\n} بنجاح.\n\nتفاصيل التبرع:\nالمبلغ: {amount} {interval, select,\n month {شهريًا}\n year {سنويًا}\n other {}\n}\n\nسترسل لك Stripe إيصالًا منفصلًا عبر البريد الإلكتروني مع فاتورتك بصيغة PDF قريبًا. يتضمن هذا جميع تفاصيل الدفع ويمكن استخدامه لأغراض ضريبية.\n\nيمكنك عرض سجل تبرعاتك، وتنزيل الفواتير، {interval, select,\n month {وإدارة أو إلغاء اشتراكك}\n year {وإدارة أو إلغاء اشتراكك}\n other {وإدارة التبرعات المستقبلية}\n} في أي وقت باستخدام هذا الرابط:\n\n{manageUrl}\n\nدعمك يساعد على استمرار {product_name}. شكرًا لك!\n\n– فريق {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "إدارة تبرعاتك لـ{product_name}",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_BG_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Благодарим ти за дарението към {product_name}",
|
||||
"body": "Здравей,\n\nБлагодарим ти за дарението към {product_name}! Твоето {interval, select,\n month {периодично дарение}\n year {периодично дарение}\n other {еднократно дарение}\n} беше {interval, select,\n month {настроено}\n year {настроено}\n other {обработено}\n} успешно.\n\nПодробности за дарението:\nСума: {amount} {currency} {interval, select,\n month {на месец}\n year {на година}\n other {}\n}\n\nСкоро Stripe ще ти изпрати по имейл отделна разписка с PDF на фактурата ти. Тя включва всички данни за плащане и може да се използва за данъчни цели.\n\nМожеш да преглеждаш историята на даренията си, да изтегляш фактури {interval, select,\n month {и да управляваш или анулираш абонамента си}\n year {и да управляваш или анулираш абонамента си}\n other {и да управляваш бъдещи дарения}\n} по всяко време, като използваш този линк:\n\n{manageUrl}\n\nТвоята подкрепа помага на {product_name} да продължи да работи. Благодарим ти!\n\n– Екип на {product_name}"
|
||||
"body": "Здравей,\n\nБлагодарим ти за дарението към {product_name}! Твоето {interval, select,\n month {периодично дарение}\n year {периодично дарение}\n other {еднократно дарение}\n} беше {interval, select,\n month {настроено}\n year {настроено}\n other {обработено}\n} успешно.\n\nПодробности за дарението:\nСума: {amount} {interval, select,\n month {на месец}\n year {на година}\n other {}\n}\n\nСкоро Stripe ще ти изпрати по имейл отделна разписка с PDF на фактурата ти. Тя включва всички данни за плащане и може да се използва за данъчни цели.\n\nМожеш да преглеждаш историята на даренията си, да изтегляш фактури {interval, select,\n month {и да управляваш или анулираш абонамента си}\n year {и да управляваш или анулираш абонамента си}\n other {и да управляваш бъдещи дарения}\n} по всяко време, като използваш този линк:\n\n{manageUrl}\n\nТвоята подкрепа помага на {product_name} да продължи да работи. Благодарим ти!\n\n– Екип на {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Управлявай даренията си за {product_name}",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_CS_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Děkujeme za váš dar pro {product_name}",
|
||||
"body": "Dobrý den,\n\nDěkujeme za váš dar pro {product_name}! Váš {interval, select,\n month {opakovaný dar}\n year {opakovaný dar}\n other {jednorázový dar}\n} byl úspěšně {interval, select,\n month {nastaven}\n year {nastaven}\n other {zpracován}\n}.\n\nPodrobnosti o daru:\nČástka: {amount} {currency} {interval, select,\n month {měsíčně}\n year {ročně}\n other {}\n}\n\nStripe vám brzy pošle samostatné potvrzení platby s fakturou v PDF. Obsahuje všechny platební údaje a lze ho použít pro daňové účely.\n\nPomocí tohoto odkazu si můžete kdykoli prohlédnout historii svých darů, stáhnout faktury {interval, select,\n month {a spravovat nebo zrušit předplatné}\n year {a spravovat nebo zrušit předplatné}\n other {a spravovat budoucí dary}\n}:\n\n{manageUrl}\n\nVaše podpora pomáhá udržovat {product_name} v chodu. Děkujeme!\n\n– Tým {product_name}"
|
||||
"body": "Dobrý den,\n\nDěkujeme za váš dar pro {product_name}! Váš {interval, select,\n month {opakovaný dar}\n year {opakovaný dar}\n other {jednorázový dar}\n} byl úspěšně {interval, select,\n month {nastaven}\n year {nastaven}\n other {zpracován}\n}.\n\nPodrobnosti o daru:\nČástka: {amount} {interval, select,\n month {měsíčně}\n year {ročně}\n other {}\n}\n\nStripe vám brzy pošle samostatné potvrzení platby s fakturou v PDF. Obsahuje všechny platební údaje a lze ho použít pro daňové účely.\n\nPomocí tohoto odkazu si můžete kdykoli prohlédnout historii svých darů, stáhnout faktury {interval, select,\n month {a spravovat nebo zrušit předplatné}\n year {a spravovat nebo zrušit předplatné}\n other {a spravovat budoucí dary}\n}:\n\n{manageUrl}\n\nVaše podpora pomáhá udržovat {product_name} v chodu. Děkujeme!\n\n– Tým {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Spravujte své dary pro {product_name}",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_DA_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Tak for din {product_name}-donation",
|
||||
"body": "Hej,\n\nTak for din donation til {product_name}! Din {interval, select,\n month {løbende donation}\n year {løbende donation}\n other {engangsdonation}\n} er blevet {interval, select,\n month {oprettet}\n year {oprettet}\n other {gennemført}\n}.\n\nDonationsdetaljer:\nBeløb: {amount} {currency} {interval, select,\n month {pr. måned}\n year {pr. år}\n other {}\n}\n\nStripe sender dig en separat kvittering med din faktura som PDF inden længe. Kvitteringen indeholder alle betalingsdetaljer og kan bruges til skatteformål.\n\nDu kan til enhver tid se din donationshistorik, downloade fakturaer {interval, select,\n month {og administrere eller opsige dit abonnement}\n year {og administrere eller opsige dit abonnement}\n other {og administrere fremtidige donationer}\n} ved at bruge dette link:\n\n{manageUrl}\n\nDin støtte hjælper med at holde {product_name} kørende. Tak!\n\n– Teamet bag {product_name}"
|
||||
"body": "Hej,\n\nTak for din donation til {product_name}! Din {interval, select,\n month {løbende donation}\n year {løbende donation}\n other {engangsdonation}\n} er blevet {interval, select,\n month {oprettet}\n year {oprettet}\n other {gennemført}\n}.\n\nDonationsdetaljer:\nBeløb: {amount} {interval, select,\n month {pr. måned}\n year {pr. år}\n other {}\n}\n\nStripe sender dig en separat kvittering med din faktura som PDF inden længe. Kvitteringen indeholder alle betalingsdetaljer og kan bruges til skatteformål.\n\nDu kan til enhver tid se din donationshistorik, downloade fakturaer {interval, select,\n month {og administrere eller opsige dit abonnement}\n year {og administrere eller opsige dit abonnement}\n other {og administrere fremtidige donationer}\n} ved at bruge dette link:\n\n{manageUrl}\n\nDin støtte hjælper med at holde {product_name} kørende. Tak!\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Administrer dine {product_name}-donationer",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_DE_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Vielen Dank für deine {product_name}-Spende",
|
||||
"body": "Hallo,\n\nvielen Dank für deine Spende an {product_name}! Deine {interval, select,\n month {wiederkehrende Spende}\n year {wiederkehrende Spende}\n other {einmalige Spende}\n} wurde erfolgreich {interval, select,\n month {eingerichtet}\n year {eingerichtet}\n other {verarbeitet}\n}.\n\nDetails zur Spende:\nBetrag: {amount} {currency} {interval, select,\n month {pro Monat}\n year {pro Jahr}\n other {}\n}\n\nStripe sendet dir in Kürze eine separate Quittung mit deiner Rechnungs-PDF per E-Mail. Diese enthält alle Zahlungsdetails und kann für Steuerzwecke verwendet werden.\n\nÜber diesen Link kannst du jederzeit deine Spendenübersicht aufrufen, Rechnungen herunterladen {interval, select,\n month {und dein Abonnement verwalten oder kündigen}\n year {und dein Abonnement verwalten oder kündigen}\n other {und zukünftige Spenden verwalten}\n}:\n\n{manageUrl}\n\nDeine Unterstützung hilft, {product_name} am Laufen zu halten. Vielen Dank!\n\n– {product_name}-Team"
|
||||
"body": "Hallo,\n\nvielen Dank für deine Spende an {product_name}! Deine {interval, select,\n month {wiederkehrende Spende}\n year {wiederkehrende Spende}\n other {einmalige Spende}\n} wurde erfolgreich {interval, select,\n month {eingerichtet}\n year {eingerichtet}\n other {verarbeitet}\n}.\n\nDetails zur Spende:\nBetrag: {amount} {interval, select,\n month {pro Monat}\n year {pro Jahr}\n other {}\n}\n\nStripe sendet dir in Kürze eine separate Quittung mit deiner Rechnungs-PDF per E-Mail. Diese enthält alle Zahlungsdetails und kann für Steuerzwecke verwendet werden.\n\nÜber diesen Link kannst du jederzeit deine Spendenübersicht aufrufen, Rechnungen herunterladen {interval, select,\n month {und dein Abonnement verwalten oder kündigen}\n year {und dein Abonnement verwalten oder kündigen}\n other {und zukünftige Spenden verwalten}\n}:\n\n{manageUrl}\n\nDeine Unterstützung hilft, {product_name} am Laufen zu halten. Vielen Dank!\n\n– {product_name}-Team"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Verwalte deine {product_name}-Spenden",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_EL_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Ευχαριστούμε για τη δωρεά σου στο {product_name}",
|
||||
"body": "Γεια σου,\n\nΕυχαριστούμε για τη δωρεά σου στο {product_name}! Η {interval, select,\n month {επαναλαμβανόμενη δωρεά}\n year {επαναλαμβανόμενη δωρεά}\n other {εφάπαξ δωρεά}\n} σου έχει {interval, select,\n month {ρυθμιστεί}\n year {ρυθμιστεί}\n other {ολοκληρωθεί}\n} με επιτυχία.\n\nΛεπτομέρειες δωρεάς:\nΠοσό: {amount} {currency} {interval, select,\n month {ανά μήνα}\n year {ανά έτος}\n other {}\n}\n\nΗ Stripe θα σου στείλει σύντομα ξεχωριστή απόδειξη με το PDF του τιμολογίου σου. Περιλαμβάνει όλες τις λεπτομέρειες πληρωμής και μπορεί να χρησιμοποιηθεί για φορολογικούς σκοπούς.\n\nΜπορείς να δεις το ιστορικό των δωρεών σου, να κατεβάσεις τιμολόγια, {interval, select,\n month {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n year {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n other {και να διαχειριστείς μελλοντικές δωρεές}\n} ανά πάσα στιγμή χρησιμοποιώντας αυτόν τον σύνδεσμο:\n\n{manageUrl}\n\nΗ υποστήριξή σου βοηθάει το {product_name} να συνεχίσει να λειτουργεί. Ευχαριστούμε!\n\n– Η ομάδα του {product_name}"
|
||||
"body": "Γεια σου,\n\nΕυχαριστούμε για τη δωρεά σου στο {product_name}! Η {interval, select,\n month {επαναλαμβανόμενη δωρεά}\n year {επαναλαμβανόμενη δωρεά}\n other {εφάπαξ δωρεά}\n} σου έχει {interval, select,\n month {ρυθμιστεί}\n year {ρυθμιστεί}\n other {ολοκληρωθεί}\n} με επιτυχία.\n\nΛεπτομέρειες δωρεάς:\nΠοσό: {amount} {interval, select,\n month {ανά μήνα}\n year {ανά έτος}\n other {}\n}\n\nΗ Stripe θα σου στείλει σύντομα ξεχωριστή απόδειξη με το PDF του τιμολογίου σου. Περιλαμβάνει όλες τις λεπτομέρειες πληρωμής και μπορεί να χρησιμοποιηθεί για φορολογικούς σκοπούς.\n\nΜπορείς να δεις το ιστορικό των δωρεών σου, να κατεβάσεις τιμολόγια, {interval, select,\n month {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n year {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n other {και να διαχειριστείς μελλοντικές δωρεές}\n} ανά πάσα στιγμή χρησιμοποιώντας αυτόν τον σύνδεσμο:\n\n{manageUrl}\n\nΗ υποστήριξή σου βοηθάει το {product_name} να συνεχίσει να λειτουργεί. Ευχαριστούμε!\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Διαχειρίσου τις δωρεές σου στο {product_name}",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_EN_GB_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Thank you for your {product_name} donation",
|
||||
"body": "Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {currency} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team"
|
||||
"body": "Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Manage your {product_name} donations",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_ES_419_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Gracias por tu donación a {product_name}",
|
||||
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} ha sido {interval, select,\n month {configurada}\n year {configurada}\n other {procesada}\n} con éxito.\n\nDetalles de la donación:\nMonto: {amount} {currency} {interval, select,\n month {por mes}\n year {por año}\n other {}\n}\n\nEn breve, Stripe te enviará por separado un correo electrónico con el recibo y la factura en PDF. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y administrar o cancelar tu suscripción}\n year {y administrar o cancelar tu suscripción}\n other {y administrar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
|
||||
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} ha sido {interval, select,\n month {configurada}\n year {configurada}\n other {procesada}\n} con éxito.\n\nDetalles de la donación:\nMonto: {amount} {interval, select,\n month {por mes}\n year {por año}\n other {}\n}\n\nEn breve, Stripe te enviará por separado un correo electrónico con el recibo y la factura en PDF. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y administrar o cancelar tu suscripción}\n year {y administrar o cancelar tu suscripción}\n other {y administrar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Administra tus donaciones a {product_name}",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_ES_ES_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Gracias por tu donación a {product_name}",
|
||||
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} se ha {interval, select,\n month {configurado}\n year {configurado}\n other {procesado}\n} correctamente.\n\nDetalles de la donación:\nImporte: {amount} {currency} {interval, select,\n month {al mes}\n year {al año}\n other {}\n}\n\nEn breve, Stripe te enviará por separado un correo electrónico con el recibo y la factura en PDF. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y gestionar o cancelar tu suscripción}\n year {y gestionar o cancelar tu suscripción}\n other {y gestionar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
|
||||
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} se ha {interval, select,\n month {configurado}\n year {configurado}\n other {procesado}\n} correctamente.\n\nDetalles de la donación:\nImporte: {amount} {interval, select,\n month {al mes}\n year {al año}\n other {}\n}\n\nEn breve, Stripe te enviará por separado un correo electrónico con el recibo y la factura en PDF. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y gestionar o cancelar tu suscripción}\n year {y gestionar o cancelar tu suscripción}\n other {y gestionar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Gestiona tus donaciones a {product_name}",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_FI_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Kiitos {product_name}-lahjoituksestasi",
|
||||
"body": "Hei,\n\nKiitos {product_name}-lahjoituksestasi! {interval, select,\n month {Toistuva lahjoituksesi}\n year {Toistuva lahjoituksesi}\n other {Kertalahjoituksesi}\n} on {interval, select,\n month {käynnistetty}\n year {käynnistetty}\n other {käsitelty}\n} onnistuneesti.\n\nLahjoituksen tiedot:\nSumma: {amount} {currency} {interval, select,\n month {kuukaudessa}\n year {vuodessa}\n other {}\n}\n\nStripe lähettää sinulle pian sähköpostitse erillisen kuitin sekä PDF-laskun. Niissä ovat kaikki maksutiedot, ja niitä voi käyttää verotuksessa.\n\nVoit tarkastella lahjoitushistoriaasi, ladata laskuja {interval, select,\n month {ja hallinnoida tai peruuttaa tilaustasi}\n year {ja hallinnoida tai peruuttaa tilaustasi}\n other {ja hallita tulevia lahjoituksia}\n} milloin tahansa käyttämällä tätä linkkiä:\n\n{manageUrl}\n\nTukesi auttaa pitämään {product_name}-palvelun toiminnassa. Kiitos!\n\n– {product_name}-tiimi"
|
||||
"body": "Hei,\n\nKiitos {product_name}-lahjoituksestasi! {interval, select,\n month {Toistuva lahjoituksesi}\n year {Toistuva lahjoituksesi}\n other {Kertalahjoituksesi}\n} on {interval, select,\n month {käynnistetty}\n year {käynnistetty}\n other {käsitelty}\n} onnistuneesti.\n\nLahjoituksen tiedot:\nSumma: {amount} {interval, select,\n month {kuukaudessa}\n year {vuodessa}\n other {}\n}\n\nStripe lähettää sinulle pian sähköpostitse erillisen kuitin sekä PDF-laskun. Niissä ovat kaikki maksutiedot, ja niitä voi käyttää verotuksessa.\n\nVoit tarkastella lahjoitushistoriaasi, ladata laskuja {interval, select,\n month {ja hallinnoida tai peruuttaa tilaustasi}\n year {ja hallinnoida tai peruuttaa tilaustasi}\n other {ja hallita tulevia lahjoituksia}\n} milloin tahansa käyttämällä tätä linkkiä:\n\n{manageUrl}\n\nTukesi auttaa pitämään {product_name}-palvelun toiminnassa. Kiitos!\n\n– {product_name}-tiimi"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Hallitse {product_name}-lahjoituksiasi",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_FR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Merci pour votre don à {product_name}",
|
||||
"body": "Bonjour,\n\nMerci pour votre don à {product_name} ! {interval, select, month {Votre don récurrent a bien été mis en place.} year {Votre don récurrent a bien été mis en place.} other {Votre don ponctuel a bien été traité.}}\n\nDétails du don :\nMontant : {amount} {currency} {interval, select, month {par mois} year {par an} other {}}\n\nStripe vous enverra bientôt un reçu séparé accompagné de votre facture PDF. Ce document contient tous les détails du paiement et peut servir de justificatif fiscal.\n\nVous pouvez consulter l’historique de vos dons, télécharger des factures {interval, select, month {et gérer ou résilier votre abonnement} year {et gérer ou résilier votre abonnement} other {et gérer vos futurs dons}} à tout moment avec ce lien :\n\n{manageUrl}\n\nVotre soutien contribue au fonctionnement de {product_name}. Merci !\n\n– L’équipe {product_name}"
|
||||
"body": "Bonjour,\n\nMerci pour votre don à {product_name} ! {interval, select, month {Votre don récurrent a bien été mis en place.} year {Votre don récurrent a bien été mis en place.} other {Votre don ponctuel a bien été traité.}}\n\nDétails du don :\nMontant : {amount} {interval, select, month {par mois} year {par an} other {}}\n\nStripe vous enverra bientôt un reçu séparé accompagné de votre facture PDF. Ce document contient tous les détails du paiement et peut servir de justificatif fiscal.\n\nVous pouvez consulter l’historique de vos dons, télécharger des factures {interval, select, month {et gérer ou résilier votre abonnement} year {et gérer ou résilier votre abonnement} other {et gérer vos futurs dons}} à tout moment avec ce lien :\n\n{manageUrl}\n\nVotre soutien contribue au fonctionnement de {product_name}. Merci !\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Gérer vos dons à {product_name}",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_HE_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "תודה על תרומתכם ל-{product_name}",
|
||||
"body": "שלום,\n\nתודה על תרומתכם ל-{product_name}! ה{interval, select,\n month {תרומה החוזרת}\n year {תרומה החוזרת}\n other {תרומה החד-פעמית}\n} שלכם {interval, select,\n month {הוגדרה}\n year {הוגדרה}\n other {בוצעה}\n} בהצלחה.\n\nפרטי התרומה:\nסכום: {amount} {currency} {interval, select,\n month {לחודש}\n year {לשנה}\n other {}\n}\n\nStripe ישלח לכם בקרוב קבלה נפרדת עם קובץ PDF של החשבונית. הקבלה תכלול את כל פרטי התשלום וניתן יהיה להשתמש בה למטרות מס.\n\nאתם יכולים לצפות בהיסטוריית התרומות שלכם, להוריד חשבוניות, {interval, select,\n month {ולנהל או לבטל את המנוי}\n year {ולנהל או לבטל את המנוי}\n other {ולנהל תרומות עתידיות}\n} בכל עת באמצעות קישור זה:\n\n{manageUrl}\n\nהתמיכה שלכם עוזרת לשמור על פעילות {product_name}. תודה!\n\nצוות {product_name}"
|
||||
"body": "שלום,\n\nתודה על תרומתכם ל-{product_name}! ה{interval, select,\n month {תרומה החוזרת}\n year {תרומה החוזרת}\n other {תרומה החד-פעמית}\n} שלכם {interval, select,\n month {הוגדרה}\n year {הוגדרה}\n other {בוצעה}\n} בהצלחה.\n\nפרטי התרומה:\nסכום: {amount} {interval, select,\n month {לחודש}\n year {לשנה}\n other {}\n}\n\nStripe ישלח לכם בקרוב קבלה נפרדת עם קובץ PDF של החשבונית. הקבלה תכלול את כל פרטי התשלום וניתן יהיה להשתמש בה למטרות מס.\n\nאתם יכולים לצפות בהיסטוריית התרומות שלכם, להוריד חשבוניות, {interval, select,\n month {ולנהל או לבטל את המנוי}\n year {ולנהל או לבטל את המנוי}\n other {ולנהל תרומות עתידיות}\n} בכל עת באמצעות קישור זה:\n\n{manageUrl}\n\nהתמיכה שלכם עוזרת לשמור על פעילות {product_name}. תודה!\n\nצוות {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "ניהול התרומות שלכם ל-{product_name}",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_HI_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "आपके {product_name} डोनेशन के लिए धन्यवाद",
|
||||
"body": "नमस्ते,\n\n{product_name} को आपके डोनेशन के लिए धन्यवाद! आपका {interval, select,\n month {रिकरिंग डोनेशन}\n year {रिकरिंग डोनेशन}\n other {वन-टाइम डोनेशन}\n} सफलतापूर्वक {interval, select,\n month {सेट अप हो गया है}\n year {सेट अप हो गया है}\n other {प्रोसेस हो गया है}\n}।\n\nडोनेशन का विवरण:\nराशि: {amount} {currency} {interval, select,\n month {प्रति माह}\n year {प्रति वर्ष}\n other {}\n}\n\nStripe आपको जल्द ही आपके इनवॉइस PDF के साथ एक अलग रसीद ईमेल करेगा। इसमें पेमेंट की सारी जानकारी शामिल है और इसे टैक्स के लिए इस्तेमाल किया जा सकता है।\n\nआप इस लिंक से किसी भी समय अपने डोनेशन का इतिहास देख सकते हैं, इनवॉइस डाउनलोड कर सकते हैं, {interval, select,\n month {और अपना सब्सक्रिप्शन मैनेज या कैंसल कर सकते हैं}\n year {और अपना सब्सक्रिप्शन मैनेज या कैंसल कर सकते हैं}\n other {और आगे के डोनेशन मैनेज कर सकते हैं}\n}।\n\n{manageUrl}\n\nआपका सपोर्ट {product_name} को चालू रखने में मदद करता है। धन्यवाद!\n\n– {product_name} टीम"
|
||||
"body": "नमस्ते,\n\n{product_name} को आपके डोनेशन के लिए धन्यवाद! आपका {interval, select,\n month {रिकरिंग डोनेशन}\n year {रिकरिंग डोनेशन}\n other {वन-टाइम डोनेशन}\n} सफलतापूर्वक {interval, select,\n month {सेट अप हो गया है}\n year {सेट अप हो गया है}\n other {प्रोसेस हो गया है}\n}।\n\nडोनेशन का विवरण:\nराशि: {amount} {interval, select,\n month {प्रति माह}\n year {प्रति वर्ष}\n other {}\n}\n\nStripe आपको जल्द ही आपके इनवॉइस PDF के साथ एक अलग रसीद ईमेल करेगा। इसमें पेमेंट की सारी जानकारी शामिल है और इसे टैक्स के लिए इस्तेमाल किया जा सकता है।\n\nआप इस लिंक से किसी भी समय अपने डोनेशन का इतिहास देख सकते हैं, इनवॉइस डाउनलोड कर सकते हैं, {interval, select,\n month {और अपना सब्सक्रिप्शन मैनेज या कैंसल कर सकते हैं}\n year {और अपना सब्सक्रिप्शन मैनेज या कैंसल कर सकते हैं}\n other {और आगे के डोनेशन मैनेज कर सकते हैं}\n}।\n\n{manageUrl}\n\nआपका सपोर्ट {product_name} को चालू रखने में मदद करता है। धन्यवाद!\n\n– {product_name} टीम"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "अपने {product_name} डोनेशन मैनेज करें",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_HR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Hvala ti na donaciji za {product_name}",
|
||||
"body": "Pozdrav,\n\nHvala ti na donaciji za {product_name}! Tvoja {interval, select,\n month {redovita donacija}\n year {redovita donacija}\n other {jednokratna donacija}\n} uspješno je {interval, select,\n month {postavljena}\n year {postavljena}\n other {obrađena}\n}.\n\nDetalji donacije:\nIznos: {amount} {currency} {interval, select,\n month {mjesečno}\n year {godišnje}\n other {}\n}\n\nStripe će ti uskoro poslati zasebnu potvrdu s PDF-om računa. Ona sadrži sve detalje plaćanja i možeš je koristiti u porezne svrhe.\n\nMožeš pregledati povijest donacija, preuzeti račune {interval, select,\n month {i upravljati pretplatom ili je otkazati}\n year {i upravljati pretplatom ili je otkazati}\n other {i upravljati budućim donacijama}\n} u bilo kojem trenutku putem ove poveznice:\n\n{manageUrl}\n\nTvoja podrška pomaže da {product_name} nastavi raditi. Hvala ti!\n\n– Tim {product_name}a"
|
||||
"body": "Pozdrav,\n\nHvala ti na donaciji za {product_name}! Tvoja {interval, select,\n month {redovita donacija}\n year {redovita donacija}\n other {jednokratna donacija}\n} uspješno je {interval, select,\n month {postavljena}\n year {postavljena}\n other {obrađena}\n}.\n\nDetalji donacije:\nIznos: {amount} {interval, select,\n month {mjesečno}\n year {godišnje}\n other {}\n}\n\nStripe će ti uskoro poslati zasebnu potvrdu s PDF-om računa. Ona sadrži sve detalje plaćanja i možeš je koristiti u porezne svrhe.\n\nMožeš pregledati povijest donacija, preuzeti račune {interval, select,\n month {i upravljati pretplatom ili je otkazati}\n year {i upravljati pretplatom ili je otkazati}\n other {i upravljati budućim donacijama}\n} u bilo kojem trenutku putem ove poveznice:\n\n{manageUrl}\n\nTvoja podrška pomaže da {product_name} nastavi raditi. Hvala ti!\n\n– Tim {product_name}a"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Upravljanje donacijama za {product_name}",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_HU_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Köszönjük, hogy adományoddal támogatod a {product_name} működését",
|
||||
"body": "Szia!\n\nKöszönjük, hogy adományoddal támogatod a {product_name} működését! {interval, select,\n month {Sikeresen beállítottuk a rendszeres adományodat.}\n year {Sikeresen beállítottuk a rendszeres adományodat.}\n other {Sikeresen feldolgoztuk az egyszeri adományodat.}\n}\n\nAz adomány részletei:\nÖsszeg: {amount} {currency} {interval, select,\n month {havonta}\n year {évente}\n other {}\n}\n\nA Stripe hamarosan külön e-mailben küld nyugtát és egy PDF-számlát. Ezek tartalmazzák az összes fizetési adatot, és adózási célokra is használhatók.\n\nAz alábbi linken bármikor megtekintheted korábbi adományaidat, letöltheted a számlákat, {interval, select,\n month {valamint kezelheted vagy lemondhatod az előfizetésedet}\n year {valamint kezelheted vagy lemondhatod az előfizetésedet}\n other {valamint kezelheted a jövőbeli adományaidat}\n}:\n\n{manageUrl}\n\nTámogatásod segít fenntartani a {product_name} működését. Köszönjük!\n\n– {product_name} csapata"
|
||||
"body": "Szia!\n\nKöszönjük, hogy adományoddal támogatod a {product_name} működését! {interval, select,\n month {Sikeresen beállítottuk a rendszeres adományodat.}\n year {Sikeresen beállítottuk a rendszeres adományodat.}\n other {Sikeresen feldolgoztuk az egyszeri adományodat.}\n}\n\nAz adomány részletei:\nÖsszeg: {amount} {interval, select,\n month {havonta}\n year {évente}\n other {}\n}\n\nA Stripe hamarosan külön e-mailben küld nyugtát és egy PDF-számlát. Ezek tartalmazzák az összes fizetési adatot, és adózási célokra is használhatók.\n\nAz alábbi linken bármikor megtekintheted korábbi adományaidat, letöltheted a számlákat, {interval, select,\n month {valamint kezelheted vagy lemondhatod az előfizetésedet}\n year {valamint kezelheted vagy lemondhatod az előfizetésedet}\n other {valamint kezelheted a jövőbeli adományaidat}\n}:\n\n{manageUrl}\n\nTámogatásod segít fenntartani a {product_name} működését. Köszönjük!\n\n– {product_name} csapata"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "A {product_name} működését támogató adományaid kezelése",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_ID_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Terima kasih atas donasimu untuk {product_name}",
|
||||
"body": "Halo,\n\nTerima kasih atas donasimu untuk {product_name}! Donasi {interval, select,\n month {berulang}\n year {berulang}\n other {satu kali}\n} kamu telah berhasil {interval, select,\n month {diatur}\n year {diatur}\n other {diproses}\n}.\n\nDetail donasi:\nJumlah: {amount} {currency} {interval, select,\n month {per bulan}\n year {per tahun}\n other {}\n}\n\nStripe akan segera mengirimi kamu email tanda terima terpisah beserta PDF fakturmu. Ini mencakup semua detail pembayaran dan bisa dipakai untuk keperluan pajak.\n\nKamu bisa melihat riwayat donasimu, mengunduh faktur, {interval, select,\n month {dan mengelola atau membatalkan langgananmu}\n year {dan mengelola atau membatalkan langgananmu}\n other {dan mengelola donasi di masa mendatang}\n} kapan saja lewat tautan ini:\n\n{manageUrl}\n\nDukunganmu membantu {product_name} tetap berjalan. Terima kasih!\n\n– Tim {product_name}"
|
||||
"body": "Halo,\n\nTerima kasih atas donasimu untuk {product_name}! Donasi {interval, select,\n month {berulang}\n year {berulang}\n other {satu kali}\n} kamu telah berhasil {interval, select,\n month {diatur}\n year {diatur}\n other {diproses}\n}.\n\nDetail donasi:\nJumlah: {amount} {interval, select,\n month {per bulan}\n year {per tahun}\n other {}\n}\n\nStripe akan segera mengirimi kamu email tanda terima terpisah beserta PDF fakturmu. Ini mencakup semua detail pembayaran dan bisa dipakai untuk keperluan pajak.\n\nKamu bisa melihat riwayat donasimu, mengunduh faktur, {interval, select,\n month {dan mengelola atau membatalkan langgananmu}\n year {dan mengelola atau membatalkan langgananmu}\n other {dan mengelola donasi di masa mendatang}\n} kapan saja lewat tautan ini:\n\n{manageUrl}\n\nDukunganmu membantu {product_name} tetap berjalan. Terima kasih!\n\n– Tim {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Kelola donasimu untuk {product_name}",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_IT_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Grazie per la tua donazione a {product_name}",
|
||||
"body": "Ciao,\n\nGrazie per la tua donazione a {product_name}! La tua {interval, select,\n month {donazione ricorrente}\n year {donazione ricorrente}\n other {donazione una tantum}\n} è stata {interval, select,\n month {attivata}\n year {attivata}\n other {elaborata}\n} con successo.\n\nDettagli della donazione:\nImporto: {amount} {currency} {interval, select,\n month {al mese}\n year {all'anno}\n other {}\n}\n\nStripe ti invierà a breve una ricevuta separata con la tua fattura in PDF. Questa include tutti i dettagli del pagamento e può essere utilizzata a fini fiscali.\n\nPuoi visualizzare la cronologia delle tue donazioni, scaricare le fatture {interval, select,\n month {e gestire o disdire il tuo abbonamento}\n year {e gestire o disdire il tuo abbonamento}\n other {e gestire le future donazioni}\n} in qualsiasi momento utilizzando questo link:\n\n{manageUrl}\n\nIl tuo supporto aiuta a mantenere {product_name} in funzione. Grazie!\n\n– Il team di {product_name}"
|
||||
"body": "Ciao,\n\nGrazie per la tua donazione a {product_name}! La tua {interval, select,\n month {donazione ricorrente}\n year {donazione ricorrente}\n other {donazione una tantum}\n} è stata {interval, select,\n month {attivata}\n year {attivata}\n other {elaborata}\n} con successo.\n\nDettagli della donazione:\nImporto: {amount} {interval, select,\n month {al mese}\n year {all'anno}\n other {}\n}\n\nStripe ti invierà a breve una ricevuta separata con la tua fattura in PDF. Questa include tutti i dettagli del pagamento e può essere utilizzata a fini fiscali.\n\nPuoi visualizzare la cronologia delle tue donazioni, scaricare le fatture {interval, select,\n month {e gestire o disdire il tuo abbonamento}\n year {e gestire o disdire il tuo abbonamento}\n other {e gestire le future donazioni}\n} in qualsiasi momento utilizzando questo link:\n\n{manageUrl}\n\nIl tuo supporto aiuta a mantenere {product_name} in funzione. Grazie!\n\n– Il team di {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Gestisci le tue donazioni a {product_name}",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_JA_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "{product_name}へのご寄付ありがとうございます",
|
||||
"body": "こんにちは。\n\n{product_name}へのご寄付ありがとうございます。{interval, select,\n month {定期寄付}\n year {定期寄付}\n other {1回のみの寄付}\n}の{interval, select,\n month {設定}\n year {設定}\n other {処理}\n}が完了しました。\n\n寄付の詳細:\n金額: {amount} {currency}{interval, select,\n month {(毎月)}\n year {(毎年)}\n other {}\n}\n\nStripeから、請求書PDF付きの領収書が別途メールで送信されます。これにはすべての支払い詳細が含まれており、税務目的にご利用いただけます。\n\n以下のリンクから、いつでも寄付履歴の表示、請求書のダウンロード、{interval, select,\n month {およびサブスクリプションの管理またはキャンセル}\n year {およびサブスクリプションの管理またはキャンセル}\n other {および今後の寄付の管理}\n}が可能です。\n\n{manageUrl}\n\n皆様のご支援が{product_name}の運営を支えています。ありがとうございます。\n\n– {product_name}チーム"
|
||||
"body": "こんにちは。\n\n{product_name}へのご寄付ありがとうございます。{interval, select,\n month {定期寄付}\n year {定期寄付}\n other {1回のみの寄付}\n}の{interval, select,\n month {設定}\n year {設定}\n other {処理}\n}が完了しました。\n\n寄付の詳細:\n金額: {amount}{interval, select,\n month {(毎月)}\n year {(毎年)}\n other {}\n}\n\nStripeから、請求書PDF付きの領収書が別途メールで送信されます。これにはすべての支払い詳細が含まれており、税務目的にご利用いただけます。\n\n以下のリンクから、いつでも寄付履歴の表示、請求書のダウンロード、{interval, select,\n month {およびサブスクリプションの管理またはキャンセル}\n year {およびサブスクリプションの管理またはキャンセル}\n other {および今後の寄付の管理}\n}が可能です。\n\n{manageUrl}\n\n皆様のご支援が{product_name}の運営を支えています。ありがとうございます。\n\n– {product_name}チーム"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "{product_name}への寄付を管理",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_KO_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "{product_name} 후원에 감사드려요",
|
||||
"body": "안녕하세요,\n\n{product_name}에 후원해 주셔서 감사해요! 회원님의 {interval, select,\n month {정기 후원}\n year {정기 후원}\n other {일회성 후원}\n}이 성공적으로 {interval, select,\n month {설정되었어요}\n year {설정되었어요}\n other {처리되었어요}\n}.\n\n후원 상세 정보:\n금액: {amount} {currency} {interval, select,\n month {매월}\n year {매년}\n other {}\n}\n\nStripe에서 곧 인보이스 PDF가 포함된 별도의 영수증을 이메일로 보내드릴 예정이에요. 이 영수증에는 모든 결제 세부 정보가 포함되어 있으며, 세금 목적으로 활용할 수 있어요.\n\n다음 링크를 통해 언제든지 후원 내역을 확인하고, 인보이스를 다운로드하고, {interval, select,\n month {구독을 관리하거나 취소할 수 있어요}\n year {구독을 관리하거나 취소할 수 있어요}\n other {향후 후원을 관리할 수 있어요}\n}:\n\n{manageUrl}\n\n회원님의 후원은 {product_name}를 계속 운영하는 데 큰 도움이 돼요. 진심으로 감사해요!\n\n– {product_name} 팀"
|
||||
"body": "안녕하세요,\n\n{product_name}에 후원해 주셔서 감사해요! 회원님의 {interval, select,\n month {정기 후원}\n year {정기 후원}\n other {일회성 후원}\n}이 성공적으로 {interval, select,\n month {설정되었어요}\n year {설정되었어요}\n other {처리되었어요}\n}.\n\n후원 상세 정보:\n금액: {amount} {interval, select,\n month {매월}\n year {매년}\n other {}\n}\n\nStripe에서 곧 인보이스 PDF가 포함된 별도의 영수증을 이메일로 보내드릴 예정이에요. 이 영수증에는 모든 결제 세부 정보가 포함되어 있으며, 세금 목적으로 활용할 수 있어요.\n\n다음 링크를 통해 언제든지 후원 내역을 확인하고, 인보이스를 다운로드하고, {interval, select,\n month {구독을 관리하거나 취소할 수 있어요}\n year {구독을 관리하거나 취소할 수 있어요}\n other {향후 후원을 관리할 수 있어요}\n}:\n\n{manageUrl}\n\n회원님의 후원은 {product_name}를 계속 운영하는 데 큰 도움이 돼요. 진심으로 감사해요!\n\n– {product_name} 팀"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "{product_name} 후원 내역 관리",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_LT_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Dėkojame už tavo {product_name} paramą",
|
||||
"body": "Sveiki,\n\nDėkojame už tavo paramą {product_name}! Tavo {interval, select,\n month {pasikartojanti parama}\n year {pasikartojanti parama}\n other {vienkartinė parama}\n} sėkmingai {interval, select,\n month {nustatyta}\n year {nustatyta}\n other {apdorota}\n}.\n\nParamos informacija:\nSuma: {amount} {currency} {interval, select,\n month {per mėnesį}\n year {per metus}\n other {}\n}\n\nStripe netrukus atsiųs tau atskirą kvitą su sąskaitos faktūros PDF. Jame nurodyta visa mokėjimo informacija ir jį galima naudoti mokesčių tikslais.\n\nBet kuriuo metu šia nuoroda gali peržiūrėti savo paramos istoriją, atsisiųsti sąskaitas faktūras {interval, select,\n month {ir valdyti arba atšaukti savo prenumeratą}\n year {ir valdyti arba atšaukti savo prenumeratą}\n other {ir valdyti būsimą paramą}\n}:\n\n{manageUrl}\n\nTavo parama padeda {product_name} veikti. Ačiū!\n\n– {product_name} komanda"
|
||||
"body": "Sveiki,\n\nDėkojame už tavo paramą {product_name}! Tavo {interval, select,\n month {pasikartojanti parama}\n year {pasikartojanti parama}\n other {vienkartinė parama}\n} sėkmingai {interval, select,\n month {nustatyta}\n year {nustatyta}\n other {apdorota}\n}.\n\nParamos informacija:\nSuma: {amount} {interval, select,\n month {per mėnesį}\n year {per metus}\n other {}\n}\n\nStripe netrukus atsiųs tau atskirą kvitą su sąskaitos faktūros PDF. Jame nurodyta visa mokėjimo informacija ir jį galima naudoti mokesčių tikslais.\n\nBet kuriuo metu šia nuoroda gali peržiūrėti savo paramos istoriją, atsisiųsti sąskaitas faktūras {interval, select,\n month {ir valdyti arba atšaukti savo prenumeratą}\n year {ir valdyti arba atšaukti savo prenumeratą}\n other {ir valdyti būsimą paramą}\n}:\n\n{manageUrl}\n\nTavo parama padeda {product_name} veikti. Ačiū!\n\n– {product_name} komanda"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Tvarkyk savo {product_name} paramą",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_NL_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Bedankt voor je {product_name}-donatie",
|
||||
"body": "Hallo,\n\nBedankt voor je donatie aan {product_name}! Je {interval, select,\n month {terugkerende donatie}\n year {terugkerende donatie}\n other {eenmalige donatie}\n} is succesvol {interval, select,\n month {ingesteld}\n year {ingesteld}\n other {verwerkt}\n}.\n\nDonatiegegevens:\nBedrag: {amount} {currency} {interval, select,\n month {per maand}\n year {per jaar}\n other {}\n}\n\nStripe stuurt je binnenkort per e-mail een apart betalingsbewijs met je factuur als pdf. Dit bevat alle betalingsgegevens en kan worden gebruikt voor belastingdoeleinden.\n\nJe kunt op elk moment je donatiegeschiedenis bekijken, facturen downloaden en {interval, select,\n month {je abonnement beheren of opzeggen}\n year {je abonnement beheren of opzeggen}\n other {toekomstige donaties beheren}\n} via deze link:\n\n{manageUrl}\n\nJe steun helpt {product_name} draaiende te houden. Bedankt!\n\n– Het team van {product_name}"
|
||||
"body": "Hallo,\n\nBedankt voor je donatie aan {product_name}! Je {interval, select,\n month {terugkerende donatie}\n year {terugkerende donatie}\n other {eenmalige donatie}\n} is succesvol {interval, select,\n month {ingesteld}\n year {ingesteld}\n other {verwerkt}\n}.\n\nDonatiegegevens:\nBedrag: {amount} {interval, select,\n month {per maand}\n year {per jaar}\n other {}\n}\n\nStripe stuurt je binnenkort per e-mail een apart betalingsbewijs met je factuur als pdf. Dit bevat alle betalingsgegevens en kan worden gebruikt voor belastingdoeleinden.\n\nJe kunt op elk moment je donatiegeschiedenis bekijken, facturen downloaden en {interval, select,\n month {je abonnement beheren of opzeggen}\n year {je abonnement beheren of opzeggen}\n other {toekomstige donaties beheren}\n} via deze link:\n\n{manageUrl}\n\nJe steun helpt {product_name} draaiende te houden. Bedankt!\n\n– Het team van {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Beheer je {product_name}-donaties",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_NO_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Takk for bidraget ditt til {product_name}",
|
||||
"body": "Hei,\n\nTakk for bidraget ditt til {product_name}! {interval, select,\n month {Det faste bidraget ditt er opprettet.}\n year {Det faste bidraget ditt er opprettet.}\n other {Engangsbidraget ditt er behandlet.}\n}\n\nDetaljer om bidraget:\nBeløp: {amount} {currency} {interval, select,\n month {per måned}\n year {per år}\n other {}\n}\n\nStripe sender deg snart en egen kvittering med fakturaen som PDF. Kvitteringen inneholder alle betalingsdetaljer og kan brukes til skatteformål.\n\nDu kan når som helst se bidragshistorikken din, laste ned fakturaer og {interval, select,\n month {administrere eller si opp abonnementet ditt}\n year {administrere eller si opp abonnementet ditt}\n other {administrere framtidige bidrag}\n} via denne lenken:\n\n{manageUrl}\n\nStøtten din bidrar til å holde {product_name} i gang. Takk!\n\n– {product_name}-teamet"
|
||||
"body": "Hei,\n\nTakk for bidraget ditt til {product_name}! {interval, select,\n month {Det faste bidraget ditt er opprettet.}\n year {Det faste bidraget ditt er opprettet.}\n other {Engangsbidraget ditt er behandlet.}\n}\n\nDetaljer om bidraget:\nBeløp: {amount} {interval, select,\n month {per måned}\n year {per år}\n other {}\n}\n\nStripe sender deg snart en egen kvittering med fakturaen som PDF. Kvitteringen inneholder alle betalingsdetaljer og kan brukes til skatteformål.\n\nDu kan når som helst se bidragshistorikken din, laste ned fakturaer og {interval, select,\n month {administrere eller si opp abonnementet ditt}\n year {administrere eller si opp abonnementet ditt}\n other {administrere framtidige bidrag}\n} via denne lenken:\n\n{manageUrl}\n\nStøtten din bidrar til å holde {product_name} i gang. Takk!\n\n– {product_name}-teamet"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Administrer {product_name}-bidragene dine",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_PL_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Dziękujemy za darowiznę dla {product_name}",
|
||||
"body": "Witaj,\n\nDziękujemy za darowiznę dla {product_name}! Twoja {interval, select,\n month {cykliczna darowizna}\n year {cykliczna darowizna}\n other {jednorazowa darowizna}\n} została {interval, select,\n month {uruchomiona}\n year {uruchomiona}\n other {przetworzona}\n} pomyślnie.\n\nSzczegóły darowizny:\nKwota: {amount} {currency} {interval, select,\n month {miesięcznie}\n year {rocznie}\n other {}\n}\n\nStripe wkrótce wyśle Ci osobną wiadomość e-mail z potwierdzeniem i fakturą w formacie PDF. Potwierdzenie zawiera wszystkie szczegóły płatności i może służyć do celów podatkowych.\n\nMożesz przeglądać historię swoich darowizn, pobierać faktury {interval, select,\n month {oraz zarządzać subskrypcją lub ją anulować}\n year {oraz zarządzać subskrypcją lub ją anulować}\n other {oraz zarządzać przyszłymi darowiznami}\n} w dowolnym momencie, korzystając z tego linku:\n\n{manageUrl}\n\nTwoje wsparcie pomaga utrzymać {product_name}. Dziękujemy!\n\n– Zespół {product_name}"
|
||||
"body": "Witaj,\n\nDziękujemy za darowiznę dla {product_name}! Twoja {interval, select,\n month {cykliczna darowizna}\n year {cykliczna darowizna}\n other {jednorazowa darowizna}\n} została {interval, select,\n month {uruchomiona}\n year {uruchomiona}\n other {przetworzona}\n} pomyślnie.\n\nSzczegóły darowizny:\nKwota: {amount} {interval, select,\n month {miesięcznie}\n year {rocznie}\n other {}\n}\n\nStripe wkrótce wyśle Ci osobną wiadomość e-mail z potwierdzeniem i fakturą w formacie PDF. Potwierdzenie zawiera wszystkie szczegóły płatności i może służyć do celów podatkowych.\n\nMożesz przeglądać historię swoich darowizn, pobierać faktury {interval, select,\n month {oraz zarządzać subskrypcją lub ją anulować}\n year {oraz zarządzać subskrypcją lub ją anulować}\n other {oraz zarządzać przyszłymi darowiznami}\n} w dowolnym momencie, korzystając z tego linku:\n\n{manageUrl}\n\nTwoje wsparcie pomaga utrzymać {product_name}. Dziękujemy!\n\n– Zespół {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Zarządzaj darowiznami dla {product_name}",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_PT_BR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Agradecemos pela sua doação ao {product_name}",
|
||||
"body": "Olá,\n\nAgradecemos pela sua doação ao {product_name}! Sua {interval, select,\n month {doação recorrente}\n year {doação recorrente}\n other {doação única}\n} foi {interval, select,\n month {configurada}\n year {configurada}\n other {processada}\n} com sucesso.\n\nDetalhes da doação:\nValor: {amount} {currency} {interval, select,\n month {por mês}\n year {por ano}\n other {}\n}\n\nA Stripe enviará um recibo separado com sua fatura em PDF em breve. Isso inclui todos os detalhes de pagamento e pode ser usado para fins fiscais.\n\nVocê pode visualizar seu histórico de doações, baixar faturas {interval, select,\n month {e gerenciar ou cancelar sua assinatura}\n year {e gerenciar ou cancelar sua assinatura}\n other {e gerenciar futuras doações}\n} a qualquer momento usando este link:\n\n{manageUrl}\n\nSeu apoio ajuda a manter o {product_name} funcionando. Agradecemos!\n\n– Equipe do {product_name}"
|
||||
"body": "Olá,\n\nAgradecemos pela sua doação ao {product_name}! Sua {interval, select,\n month {doação recorrente}\n year {doação recorrente}\n other {doação única}\n} foi {interval, select,\n month {configurada}\n year {configurada}\n other {processada}\n} com sucesso.\n\nDetalhes da doação:\nValor: {amount} {interval, select,\n month {por mês}\n year {por ano}\n other {}\n}\n\nA Stripe enviará um recibo separado com sua fatura em PDF em breve. Isso inclui todos os detalhes de pagamento e pode ser usado para fins fiscais.\n\nVocê pode visualizar seu histórico de doações, baixar faturas {interval, select,\n month {e gerenciar ou cancelar sua assinatura}\n year {e gerenciar ou cancelar sua assinatura}\n other {e gerenciar futuras doações}\n} a qualquer momento usando este link:\n\n{manageUrl}\n\nSeu apoio ajuda a manter o {product_name} funcionando. Agradecemos!\n\n– Equipe do {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Gerencie suas doações ao {product_name}",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_RO_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Îți mulțumim pentru donația către {product_name}",
|
||||
"body": "Salut,\n\nÎți mulțumim pentru donația ta către {product_name}! {interval, select,\n month {Donația ta recurentă}\n year {Donația ta recurentă}\n other {Donația ta unică}\n} a fost {interval, select,\n month {configurată}\n year {configurată}\n other {procesată}\n} cu succes.\n\nDetalii donație:\nSumă: {amount} {currency} {interval, select,\n month {pe lună}\n year {pe an}\n other {}\n}\n\nStripe îți va trimite în scurt timp un e-mail separat cu chitanța și factura ta în format PDF. Acestea includ toate detaliile plății și pot fi folosite în scopuri fiscale.\n\nPoți vizualiza istoricul donațiilor, descărca facturi {interval, select,\n month {și gestiona sau anula abonamentul}\n year {și gestiona sau anula abonamentul}\n other {și gestiona donațiile viitoare}\n} în orice moment folosind acest link:\n\n{manageUrl}\n\nSprijinul tău ne ajută să menținem {product_name} în funcțiune. Mulțumim!\n\n– Echipa {product_name}"
|
||||
"body": "Salut,\n\nÎți mulțumim pentru donația ta către {product_name}! {interval, select,\n month {Donația ta recurentă}\n year {Donația ta recurentă}\n other {Donația ta unică}\n} a fost {interval, select,\n month {configurată}\n year {configurată}\n other {procesată}\n} cu succes.\n\nDetalii donație:\nSumă: {amount} {interval, select,\n month {pe lună}\n year {pe an}\n other {}\n}\n\nStripe îți va trimite în scurt timp un e-mail separat cu chitanța și factura ta în format PDF. Acestea includ toate detaliile plății și pot fi folosite în scopuri fiscale.\n\nPoți vizualiza istoricul donațiilor, descărca facturi {interval, select,\n month {și gestiona sau anula abonamentul}\n year {și gestiona sau anula abonamentul}\n other {și gestiona donațiile viitoare}\n} în orice moment folosind acest link:\n\n{manageUrl}\n\nSprijinul tău ne ajută să menținem {product_name} în funcțiune. Mulțumim!\n\n– Echipa {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Gestionează-ți donațiile către {product_name}",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_RU_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Спасибо за твоё пожертвование для {product_name}",
|
||||
"body": "Привет,\n\nСпасибо за твоё пожертвование для {product_name}! Твоё {interval, select,\n month {регулярное пожертвование}\n year {регулярное пожертвование}\n other {разовое пожертвование}\n} успешно {interval, select,\n month {настроено}\n year {настроено}\n other {обработано}\n}.\n\nДетали пожертвования:\nСумма: {amount} {currency} {interval, select,\n month {в месяц}\n year {в год}\n other {}\n}\n\nStripe вскоре отправит тебе отдельную квитанцию со счётом в формате PDF. В ней есть все платёжные данные, и её можно использовать для налоговых целей.\n\nТы можешь просматривать историю своих пожертвований, скачивать счета, {interval, select,\n month {а также управлять подпиской или отменить её}\n year {а также управлять подпиской или отменить её}\n other {и управлять будущими пожертвованиями}\n} в любое время по этой ссылке:\n\n{manageUrl}\n\nТвоя поддержка помогает {product_name} работать. Спасибо!\n\n– Команда {product_name}"
|
||||
"body": "Привет,\n\nСпасибо за твоё пожертвование для {product_name}! Твоё {interval, select,\n month {регулярное пожертвование}\n year {регулярное пожертвование}\n other {разовое пожертвование}\n} успешно {interval, select,\n month {настроено}\n year {настроено}\n other {обработано}\n}.\n\nДетали пожертвования:\nСумма: {amount} {interval, select,\n month {в месяц}\n year {в год}\n other {}\n}\n\nStripe вскоре отправит тебе отдельную квитанцию со счётом в формате PDF. В ней есть все платёжные данные, и её можно использовать для налоговых целей.\n\nТы можешь просматривать историю своих пожертвований, скачивать счета, {interval, select,\n month {а также управлять подпиской или отменить её}\n year {а также управлять подпиской или отменить её}\n other {и управлять будущими пожертвованиями}\n} в любое время по этой ссылке:\n\n{manageUrl}\n\nТвоя поддержка помогает {product_name} работать. Спасибо!\n\n– Команда {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Управляй своими пожертвованиями для {product_name}",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_SV_SE_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Tack för din donation till {product_name}",
|
||||
"body": "Hej,\n\nTack för din donation till {product_name}! {interval, select,\n month {Din återkommande donation har registrerats.}\n year {Din återkommande donation har registrerats.}\n other {Din engångsdonation har genomförts.}\n}\n\nDonationsuppgifter:\nBelopp: {amount} {currency} {interval, select,\n month {per månad}\n year {per år}\n other {}\n}\n\nStripe skickar snart ett separat kvitto med din faktura i PDF-format via e-post. Där finns alla betalningsuppgifter, och underlaget kan användas för skatteändamål.\n\nDu kan se din donationshistorik, ladda ner fakturor {interval, select,\n month {och hantera eller avsluta din återkommande donation}\n year {och hantera eller avsluta din återkommande donation}\n other {och hantera framtida donationer}\n} när som helst via den här länken:\n\n{manageUrl}\n\nDitt stöd hjälper till att hålla {product_name} igång. Tack!\n\n– {product_name}-teamet"
|
||||
"body": "Hej,\n\nTack för din donation till {product_name}! {interval, select,\n month {Din återkommande donation har registrerats.}\n year {Din återkommande donation har registrerats.}\n other {Din engångsdonation har genomförts.}\n}\n\nDonationsuppgifter:\nBelopp: {amount} {interval, select,\n month {per månad}\n year {per år}\n other {}\n}\n\nStripe skickar snart ett separat kvitto med din faktura i PDF-format via e-post. Där finns alla betalningsuppgifter, och underlaget kan användas för skatteändamål.\n\nDu kan se din donationshistorik, ladda ner fakturor {interval, select,\n month {och hantera eller avsluta din återkommande donation}\n year {och hantera eller avsluta din återkommande donation}\n other {och hantera framtida donationer}\n} när som helst via den här länken:\n\n{manageUrl}\n\nDitt stöd hjälper till att hålla {product_name} igång. Tack!\n\n– {product_name}-teamet"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Hantera dina donationer till {product_name}",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_TH_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "ขอบคุณสำหรับการบริจาคให้ {product_name}",
|
||||
"body": "สวัสดี,\n\nขอบคุณสำหรับการบริจาคให้ {product_name}! {interval, select,\n month {การบริจาคแบบต่อเนื่อง}\n year {การบริจาคแบบต่อเนื่อง}\n other {การบริจาคครั้งเดียว}\n}ของคุณได้รับการ{interval, select,\n month {ตั้งค่า}\n year {ตั้งค่า}\n other {ดำเนินการ}\n}เรียบร้อยแล้ว\n\nรายละเอียดการบริจาค:\nจำนวนเงิน: {amount} {currency} {interval, select,\n month {ต่อเดือน}\n year {ต่อปี}\n other {}\n}\n\nStripe จะส่งอีเมลใบเสร็จแยกต่างหากพร้อมไฟล์ PDF ใบแจ้งหนี้ให้คุณในไม่ช้า ใบเสร็จนี้มีรายละเอียดการชำระเงินทั้งหมดและใช้สำหรับวัตถุประสงค์ทางภาษีได้\n\nคุณสามารถดูประวัติการบริจาค ดาวน์โหลดใบแจ้งหนี้ {interval, select,\n month {และจัดการหรือยกเลิกการสมัครสมาชิกของคุณ}\n year {และจัดการหรือยกเลิกการสมัครสมาชิกของคุณ}\n other {และจัดการการบริจาคในอนาคต}\n} ได้ตลอดเวลาโดยใช้ลิงก์นี้:\n\n{manageUrl}\n\nการสนับสนุนของคุณช่วยให้ {product_name} ดำเนินต่อไปได้ ขอบคุณ!\n\n– ทีม {product_name}"
|
||||
"body": "สวัสดี,\n\nขอบคุณสำหรับการบริจาคให้ {product_name}! {interval, select,\n month {การบริจาคแบบต่อเนื่อง}\n year {การบริจาคแบบต่อเนื่อง}\n other {การบริจาคครั้งเดียว}\n}ของคุณได้รับการ{interval, select,\n month {ตั้งค่า}\n year {ตั้งค่า}\n other {ดำเนินการ}\n}เรียบร้อยแล้ว\n\nรายละเอียดการบริจาค:\nจำนวนเงิน: {amount} {interval, select,\n month {ต่อเดือน}\n year {ต่อปี}\n other {}\n}\n\nStripe จะส่งอีเมลใบเสร็จแยกต่างหากพร้อมไฟล์ PDF ใบแจ้งหนี้ให้คุณในไม่ช้า ใบเสร็จนี้มีรายละเอียดการชำระเงินทั้งหมดและใช้สำหรับวัตถุประสงค์ทางภาษีได้\n\nคุณสามารถดูประวัติการบริจาค ดาวน์โหลดใบแจ้งหนี้ {interval, select,\n month {และจัดการหรือยกเลิกการสมัครสมาชิกของคุณ}\n year {และจัดการหรือยกเลิกการสมัครสมาชิกของคุณ}\n other {และจัดการการบริจาคในอนาคต}\n} ได้ตลอดเวลาโดยใช้ลิงก์นี้:\n\n{manageUrl}\n\nการสนับสนุนของคุณช่วยให้ {product_name} ดำเนินต่อไปได้ ขอบคุณ!\n\n– ทีม {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "จัดการการบริจาคให้ {product_name}",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_TR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "{product_name}'a yaptığın bağış için teşekkürler",
|
||||
"body": "Merhaba,\n\n{product_name}'a yaptığın bağış için teşekkürler! {interval, select,\n month {tekrarlayan bağışın}\n year {tekrarlayan bağışın}\n other {tek seferlik bağışın}\n} başarıyla {interval, select,\n month {ayarlandı}\n year {ayarlandı}\n other {işlendi}\n}.\n\nBağış detayları:\nTutar: {amount} {currency} {interval, select,\n month {aylık}\n year {yıllık}\n other {}\n}\n\nStripe, kısa süre içinde fatura PDF'ini içeren ayrı bir makbuzu sana e-posta ile gönderecektir. Bu, tüm ödeme detaylarını içerir ve vergi amaçlı kullanılabilir.\n\nBu bağlantıyı kullanarak istediğin zaman bağış geçmişini görüntüleyebilir, faturaları indirebilir, {interval, select,\n month {ve aboneliğini yönetebilir veya iptal edebilirsin}\n year {ve aboneliğini yönetebilir veya iptal edebilirsin}\n other {ve gelecekteki bağışlarını yönetebilirsin}\n}:\n\n{manageUrl}\n\nDesteğin {product_name}'ın çalışmaya devam etmesine yardımcı oluyor. Teşekkürler!\n\n– {product_name} Ekibi"
|
||||
"body": "Merhaba,\n\n{product_name}'a yaptığın bağış için teşekkürler! {interval, select,\n month {tekrarlayan bağışın}\n year {tekrarlayan bağışın}\n other {tek seferlik bağışın}\n} başarıyla {interval, select,\n month {ayarlandı}\n year {ayarlandı}\n other {işlendi}\n}.\n\nBağış detayları:\nTutar: {amount} {interval, select,\n month {aylık}\n year {yıllık}\n other {}\n}\n\nStripe, kısa süre içinde fatura PDF'ini içeren ayrı bir makbuzu sana e-posta ile gönderecektir. Bu, tüm ödeme detaylarını içerir ve vergi amaçlı kullanılabilir.\n\nBu bağlantıyı kullanarak istediğin zaman bağış geçmişini görüntüleyebilir, faturaları indirebilir, {interval, select,\n month {ve aboneliğini yönetebilir veya iptal edebilirsin}\n year {ve aboneliğini yönetebilir veya iptal edebilirsin}\n other {ve gelecekteki bağışlarını yönetebilirsin}\n}:\n\n{manageUrl}\n\nDesteğin {product_name}'ın çalışmaya devam etmesine yardımcı oluyor. Teşekkürler!\n\n– {product_name} Ekibi"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "{product_name} bağışlarını yönet",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_UK_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Дякуємо за твою пожертву для {product_name}",
|
||||
"body": "Привіт,\n\nДякуємо за твою пожертву для {product_name}! Твою {interval, select,\n month {регулярну пожертву}\n year {регулярну пожертву}\n other {одноразову пожертву}\n} успішно {interval, select,\n month {налаштовано}\n year {налаштовано}\n other {оброблено}\n}.\n\nДеталі пожертви:\nСума: {amount} {currency} {interval, select,\n month {на місяць}\n year {на рік}\n other {}\n}\n\nStripe незабаром надішле тобі окрему квитанцію з рахунком у форматі PDF. Вона містить усі платіжні дані, і її можна використати для податкових цілей.\n\nТи можеш переглядати історію своїх пожертв, завантажувати рахунки, {interval, select,\n month {а також керувати підпискою або скасувати її}\n year {а також керувати підпискою або скасувати її}\n other {а також керувати майбутніми пожертвами}\n} у будь-який час за цим посиланням:\n\n{manageUrl}\n\nТвоя підтримка допомагає {product_name} працювати. Дякуємо!\n\n– Команда {product_name}"
|
||||
"body": "Привіт,\n\nДякуємо за твою пожертву для {product_name}! Твою {interval, select,\n month {регулярну пожертву}\n year {регулярну пожертву}\n other {одноразову пожертву}\n} успішно {interval, select,\n month {налаштовано}\n year {налаштовано}\n other {оброблено}\n}.\n\nДеталі пожертви:\nСума: {amount} {interval, select,\n month {на місяць}\n year {на рік}\n other {}\n}\n\nStripe незабаром надішле тобі окрему квитанцію з рахунком у форматі PDF. Вона містить усі платіжні дані, і її можна використати для податкових цілей.\n\nТи можеш переглядати історію своїх пожертв, завантажувати рахунки, {interval, select,\n month {а також керувати підпискою або скасувати її}\n year {а також керувати підпискою або скасувати її}\n other {а також керувати майбутніми пожертвами}\n} у будь-який час за цим посиланням:\n\n{manageUrl}\n\nТвоя підтримка допомагає {product_name} працювати. Дякуємо!\n\n– Команда {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Керуй своїми пожертвами для {product_name}",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_VI_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Cảm ơn bạn đã ủng hộ {product_name}",
|
||||
"body": "Xin chào,\n\nCảm ơn bạn đã ủng hộ {product_name}! {interval, select,\n month {Khoản ủng hộ định kỳ}\n year {Khoản ủng hộ định kỳ}\n other {Khoản ủng hộ một lần}\n} của bạn đã được {interval, select,\n month {thiết lập}\n year {thiết lập}\n other {xử lý}\n} thành công.\n\nChi tiết ủng hộ:\nSố tiền: {amount} {currency} {interval, select,\n month {mỗi tháng}\n year {mỗi năm}\n other {}\n}\n\nStripe sẽ sớm gửi cho bạn một email biên lai riêng kèm hóa đơn PDF. Biên lai này gồm đầy đủ chi tiết thanh toán và có thể dùng cho mục đích thuế.\n\nBạn có thể xem lịch sử ủng hộ, tải xuống hóa đơn, {interval, select,\n month {và quản lý hoặc hủy gói đăng ký}\n year {và quản lý hoặc hủy gói đăng ký}\n other {và quản lý các khoản ủng hộ trong tương lai}\n} bất cứ lúc nào bằng cách sử dụng liên kết này:\n\n{manageUrl}\n\nSự hỗ trợ của bạn giúp {product_name} tiếp tục hoạt động. Cảm ơn bạn!\n\n– Đội ngũ {product_name}"
|
||||
"body": "Xin chào,\n\nCảm ơn bạn đã ủng hộ {product_name}! {interval, select,\n month {Khoản ủng hộ định kỳ}\n year {Khoản ủng hộ định kỳ}\n other {Khoản ủng hộ một lần}\n} của bạn đã được {interval, select,\n month {thiết lập}\n year {thiết lập}\n other {xử lý}\n} thành công.\n\nChi tiết ủng hộ:\nSố tiền: {amount} {interval, select,\n month {mỗi tháng}\n year {mỗi năm}\n other {}\n}\n\nStripe sẽ sớm gửi cho bạn một email biên lai riêng kèm hóa đơn PDF. Biên lai này gồm đầy đủ chi tiết thanh toán và có thể dùng cho mục đích thuế.\n\nBạn có thể xem lịch sử ủng hộ, tải xuống hóa đơn, {interval, select,\n month {và quản lý hoặc hủy gói đăng ký}\n year {và quản lý hoặc hủy gói đăng ký}\n other {và quản lý các khoản ủng hộ trong tương lai}\n} bất cứ lúc nào bằng cách sử dụng liên kết này:\n\n{manageUrl}\n\nSự hỗ trợ của bạn giúp {product_name} tiếp tục hoạt động. Cảm ơn bạn!\n\n– Đội ngũ {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Quản lý các khoản ủng hộ {product_name} của bạn",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_ZH_CN_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "感谢你的 {product_name} 捐赠",
|
||||
"body": "你好,\n\n感谢你对 {product_name} 的捐赠!你的{interval, select,\n month {定期捐赠}\n year {定期捐赠}\n other {一次性捐赠}\n}{interval, select,\n month {已成功设置}\n year {已成功设置}\n other {已成功处理}\n}。\n\n捐赠详情:\n金额:{amount} {currency} {interval, select,\n month {每月}\n year {每年}\n other {}\n}\n\nStripe 稍后会另外发送一封收据邮件,其中附有发票 PDF。邮件包含所有付款详情,可用于报税。\n\n你可以随时使用此链接查看你的捐赠历史、下载发票,以及{interval, select,\n month {管理或取消你的订阅}\n year {管理或取消你的订阅}\n other {管理你的未来捐赠}\n}:\n\n{manageUrl}\n\n你的支持有助于 {product_name} 的持续运行。谢谢!\n\n– {product_name} 团队"
|
||||
"body": "你好,\n\n感谢你对 {product_name} 的捐赠!你的{interval, select,\n month {定期捐赠}\n year {定期捐赠}\n other {一次性捐赠}\n}{interval, select,\n month {已成功设置}\n year {已成功设置}\n other {已成功处理}\n}。\n\n捐赠详情:\n金额:{amount} {interval, select,\n month {每月}\n year {每年}\n other {}\n}\n\nStripe 稍后会另外发送一封收据邮件,其中附有发票 PDF。邮件包含所有付款详情,可用于报税。\n\n你可以随时使用此链接查看你的捐赠历史、下载发票,以及{interval, select,\n month {管理或取消你的订阅}\n year {管理或取消你的订阅}\n other {管理你的未来捐赠}\n}:\n\n{manageUrl}\n\n你的支持有助于 {product_name} 的持续运行。谢谢!\n\n– {product_name} 团队"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "管理你的 {product_name} 捐赠",
|
||||
|
||||
@@ -17,7 +17,7 @@ const EMAIL_I18N_ZH_TW_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "感謝您對 {product_name} 的捐款",
|
||||
"body": "哈囉,\n\n感謝您對 {product_name} 的捐款!您的{interval, select,\n month {定期捐款}\n year {定期捐款}\n other {一次性捐款}\n}已成功{interval, select,\n month {設定}\n year {設定}\n other {處理}\n}。\n\n捐款詳情:\n金額:{amount} {currency} {interval, select,\n month {每月}\n year {每年}\n other {}\n}\n\nStripe 稍後會另外寄給您一封收據,其中附有發票 PDF。這份收據包含所有付款詳情,可用於報稅。\n\n您可以隨時使用此連結查看您的捐款紀錄、下載發票,{interval, select,\n month {以及管理或取消您的訂閱}\n year {以及管理或取消您的訂閱}\n other {以及管理未來的捐款}\n}:\n\n{manageUrl}\n\n您的支持讓 {product_name} 得以持續運作。謝謝您!\n\n– {product_name} 團隊"
|
||||
"body": "哈囉,\n\n感謝您對 {product_name} 的捐款!您的{interval, select,\n month {定期捐款}\n year {定期捐款}\n other {一次性捐款}\n}已成功{interval, select,\n month {設定}\n year {設定}\n other {處理}\n}。\n\n捐款詳情:\n金額:{amount} {interval, select,\n month {每月}\n year {每年}\n other {}\n}\n\nStripe 稍後會另外寄給您一封收據,其中附有發票 PDF。這份收據包含所有付款詳情,可用於報稅。\n\n您可以隨時使用此連結查看您的捐款紀錄、下載發票,{interval, select,\n month {以及管理或取消您的訂閱}\n year {以及管理或取消您的訂閱}\n other {以及管理未來的捐款}\n}:\n\n{manageUrl}\n\n您的支持讓 {product_name} 得以持續運作。謝謝您!\n\n– {product_name} 團隊"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "管理您的 {product_name} 捐款",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "شكرًا لك على تبرعك لـ{product_name}",
|
||||
"body": "مرحبًا،\n\nشكرًا لك على تبرعك لـ{product_name}! لقد تم {interval, select,\n month {إعداد تبرعك المتكرر}\n year {إعداد تبرعك المتكرر}\n other {معالجة تبرعك لمرة واحدة}\n} بنجاح.\n\nتفاصيل التبرع:\nالمبلغ: {amount} {currency} {interval, select,\n month {شهريًا}\n year {سنويًا}\n other {}\n}\n\nسترسل لك Stripe إيصالًا منفصلًا عبر البريد الإلكتروني مع فاتورتك بصيغة PDF قريبًا. يتضمن هذا جميع تفاصيل الدفع ويمكن استخدامه لأغراض ضريبية.\n\nيمكنك عرض سجل تبرعاتك، وتنزيل الفواتير، {interval, select,\n month {وإدارة أو إلغاء اشتراكك}\n year {وإدارة أو إلغاء اشتراكك}\n other {وإدارة التبرعات المستقبلية}\n} في أي وقت باستخدام هذا الرابط:\n\n{manageUrl}\n\nدعمك يساعد على استمرار {product_name}. شكرًا لك!\n\n– فريق {product_name}"
|
||||
"body": "مرحبًا،\n\nشكرًا لك على تبرعك لـ{product_name}! لقد تم {interval, select,\n month {إعداد تبرعك المتكرر}\n year {إعداد تبرعك المتكرر}\n other {معالجة تبرعك لمرة واحدة}\n} بنجاح.\n\nتفاصيل التبرع:\nالمبلغ: {amount} {interval, select,\n month {شهريًا}\n year {سنويًا}\n other {}\n}\n\nسترسل لك Stripe إيصالًا منفصلًا عبر البريد الإلكتروني مع فاتورتك بصيغة PDF قريبًا. يتضمن هذا جميع تفاصيل الدفع ويمكن استخدامه لأغراض ضريبية.\n\nيمكنك عرض سجل تبرعاتك، وتنزيل الفواتير، {interval, select,\n month {وإدارة أو إلغاء اشتراكك}\n year {وإدارة أو إلغاء اشتراكك}\n other {وإدارة التبرعات المستقبلية}\n} في أي وقت باستخدام هذا الرابط:\n\n{manageUrl}\n\nدعمك يساعد على استمرار {product_name}. شكرًا لك!\n\n– فريق {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "إدارة تبرعاتك لـ{product_name}",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Благодарим ти за дарението към {product_name}",
|
||||
"body": "Здравей,\n\nБлагодарим ти за дарението към {product_name}! Твоето {interval, select,\n month {периодично дарение}\n year {периодично дарение}\n other {еднократно дарение}\n} беше {interval, select,\n month {настроено}\n year {настроено}\n other {обработено}\n} успешно.\n\nПодробности за дарението:\nСума: {amount} {currency} {interval, select,\n month {на месец}\n year {на година}\n other {}\n}\n\nСкоро Stripe ще ти изпрати по имейл отделна разписка с PDF на фактурата ти. Тя включва всички данни за плащане и може да се използва за данъчни цели.\n\nМожеш да преглеждаш историята на даренията си, да изтегляш фактури {interval, select,\n month {и да управляваш или анулираш абонамента си}\n year {и да управляваш или анулираш абонамента си}\n other {и да управляваш бъдещи дарения}\n} по всяко време, като използваш този линк:\n\n{manageUrl}\n\nТвоята подкрепа помага на {product_name} да продължи да работи. Благодарим ти!\n\n– Екип на {product_name}"
|
||||
"body": "Здравей,\n\nБлагодарим ти за дарението към {product_name}! Твоето {interval, select,\n month {периодично дарение}\n year {периодично дарение}\n other {еднократно дарение}\n} беше {interval, select,\n month {настроено}\n year {настроено}\n other {обработено}\n} успешно.\n\nПодробности за дарението:\nСума: {amount} {interval, select,\n month {на месец}\n year {на година}\n other {}\n}\n\nСкоро Stripe ще ти изпрати по имейл отделна разписка с PDF на фактурата ти. Тя включва всички данни за плащане и може да се използва за данъчни цели.\n\nМожеш да преглеждаш историята на даренията си, да изтегляш фактури {interval, select,\n month {и да управляваш или анулираш абонамента си}\n year {и да управляваш или анулираш абонамента си}\n other {и да управляваш бъдещи дарения}\n} по всяко време, като използваш този линк:\n\n{manageUrl}\n\nТвоята подкрепа помага на {product_name} да продължи да работи. Благодарим ти!\n\n– Екип на {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Управлявай даренията си за {product_name}",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Děkujeme za váš dar pro {product_name}",
|
||||
"body": "Dobrý den,\n\nDěkujeme za váš dar pro {product_name}! Váš {interval, select,\n month {opakovaný dar}\n year {opakovaný dar}\n other {jednorázový dar}\n} byl úspěšně {interval, select,\n month {nastaven}\n year {nastaven}\n other {zpracován}\n}.\n\nPodrobnosti o daru:\nČástka: {amount} {currency} {interval, select,\n month {měsíčně}\n year {ročně}\n other {}\n}\n\nStripe vám brzy pošle samostatné potvrzení platby s fakturou v PDF. Obsahuje všechny platební údaje a lze ho použít pro daňové účely.\n\nPomocí tohoto odkazu si můžete kdykoli prohlédnout historii svých darů, stáhnout faktury {interval, select,\n month {a spravovat nebo zrušit předplatné}\n year {a spravovat nebo zrušit předplatné}\n other {a spravovat budoucí dary}\n}:\n\n{manageUrl}\n\nVaše podpora pomáhá udržovat {product_name} v chodu. Děkujeme!\n\n– Tým {product_name}"
|
||||
"body": "Dobrý den,\n\nDěkujeme za váš dar pro {product_name}! Váš {interval, select,\n month {opakovaný dar}\n year {opakovaný dar}\n other {jednorázový dar}\n} byl úspěšně {interval, select,\n month {nastaven}\n year {nastaven}\n other {zpracován}\n}.\n\nPodrobnosti o daru:\nČástka: {amount} {interval, select,\n month {měsíčně}\n year {ročně}\n other {}\n}\n\nStripe vám brzy pošle samostatné potvrzení platby s fakturou v PDF. Obsahuje všechny platební údaje a lze ho použít pro daňové účely.\n\nPomocí tohoto odkazu si můžete kdykoli prohlédnout historii svých darů, stáhnout faktury {interval, select,\n month {a spravovat nebo zrušit předplatné}\n year {a spravovat nebo zrušit předplatné}\n other {a spravovat budoucí dary}\n}:\n\n{manageUrl}\n\nVaše podpora pomáhá udržovat {product_name} v chodu. Děkujeme!\n\n– Tým {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Spravujte své dary pro {product_name}",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Tak for din {product_name}-donation",
|
||||
"body": "Hej,\n\nTak for din donation til {product_name}! Din {interval, select,\n month {løbende donation}\n year {løbende donation}\n other {engangsdonation}\n} er blevet {interval, select,\n month {oprettet}\n year {oprettet}\n other {gennemført}\n}.\n\nDonationsdetaljer:\nBeløb: {amount} {currency} {interval, select,\n month {pr. måned}\n year {pr. år}\n other {}\n}\n\nStripe sender dig en separat kvittering med din faktura som PDF inden længe. Kvitteringen indeholder alle betalingsdetaljer og kan bruges til skatteformål.\n\nDu kan til enhver tid se din donationshistorik, downloade fakturaer {interval, select,\n month {og administrere eller opsige dit abonnement}\n year {og administrere eller opsige dit abonnement}\n other {og administrere fremtidige donationer}\n} ved at bruge dette link:\n\n{manageUrl}\n\nDin støtte hjælper med at holde {product_name} kørende. Tak!\n\n– Teamet bag {product_name}"
|
||||
"body": "Hej,\n\nTak for din donation til {product_name}! Din {interval, select,\n month {løbende donation}\n year {løbende donation}\n other {engangsdonation}\n} er blevet {interval, select,\n month {oprettet}\n year {oprettet}\n other {gennemført}\n}.\n\nDonationsdetaljer:\nBeløb: {amount} {interval, select,\n month {pr. måned}\n year {pr. år}\n other {}\n}\n\nStripe sender dig en separat kvittering med din faktura som PDF inden længe. Kvitteringen indeholder alle betalingsdetaljer og kan bruges til skatteformål.\n\nDu kan til enhver tid se din donationshistorik, downloade fakturaer {interval, select,\n month {og administrere eller opsige dit abonnement}\n year {og administrere eller opsige dit abonnement}\n other {og administrere fremtidige donationer}\n} ved at bruge dette link:\n\n{manageUrl}\n\nDin støtte hjælper med at holde {product_name} kørende. Tak!\n\n– Teamet bag {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Administrer dine {product_name}-donationer",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Vielen Dank für deine {product_name}-Spende",
|
||||
"body": "Hallo,\n\nvielen Dank für deine Spende an {product_name}! Deine {interval, select,\n month {wiederkehrende Spende}\n year {wiederkehrende Spende}\n other {einmalige Spende}\n} wurde erfolgreich {interval, select,\n month {eingerichtet}\n year {eingerichtet}\n other {verarbeitet}\n}.\n\nDetails zur Spende:\nBetrag: {amount} {currency} {interval, select,\n month {pro Monat}\n year {pro Jahr}\n other {}\n}\n\nStripe sendet dir in Kürze eine separate Quittung mit deiner Rechnungs-PDF per E-Mail. Diese enthält alle Zahlungsdetails und kann für Steuerzwecke verwendet werden.\n\nÜber diesen Link kannst du jederzeit deine Spendenübersicht aufrufen, Rechnungen herunterladen {interval, select,\n month {und dein Abonnement verwalten oder kündigen}\n year {und dein Abonnement verwalten oder kündigen}\n other {und zukünftige Spenden verwalten}\n}:\n\n{manageUrl}\n\nDeine Unterstützung hilft, {product_name} am Laufen zu halten. Vielen Dank!\n\n– {product_name}-Team"
|
||||
"body": "Hallo,\n\nvielen Dank für deine Spende an {product_name}! Deine {interval, select,\n month {wiederkehrende Spende}\n year {wiederkehrende Spende}\n other {einmalige Spende}\n} wurde erfolgreich {interval, select,\n month {eingerichtet}\n year {eingerichtet}\n other {verarbeitet}\n}.\n\nDetails zur Spende:\nBetrag: {amount} {interval, select,\n month {pro Monat}\n year {pro Jahr}\n other {}\n}\n\nStripe sendet dir in Kürze eine separate Quittung mit deiner Rechnungs-PDF per E-Mail. Diese enthält alle Zahlungsdetails und kann für Steuerzwecke verwendet werden.\n\nÜber diesen Link kannst du jederzeit deine Spendenübersicht aufrufen, Rechnungen herunterladen {interval, select,\n month {und dein Abonnement verwalten oder kündigen}\n year {und dein Abonnement verwalten oder kündigen}\n other {und zukünftige Spenden verwalten}\n}:\n\n{manageUrl}\n\nDeine Unterstützung hilft, {product_name} am Laufen zu halten. Vielen Dank!\n\n– {product_name}-Team"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Verwalte deine {product_name}-Spenden",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Ευχαριστούμε για τη δωρεά σου στο {product_name}",
|
||||
"body": "Γεια σου,\n\nΕυχαριστούμε για τη δωρεά σου στο {product_name}! Η {interval, select,\n month {επαναλαμβανόμενη δωρεά}\n year {επαναλαμβανόμενη δωρεά}\n other {εφάπαξ δωρεά}\n} σου έχει {interval, select,\n month {ρυθμιστεί}\n year {ρυθμιστεί}\n other {ολοκληρωθεί}\n} με επιτυχία.\n\nΛεπτομέρειες δωρεάς:\nΠοσό: {amount} {currency} {interval, select,\n month {ανά μήνα}\n year {ανά έτος}\n other {}\n}\n\nΗ Stripe θα σου στείλει σύντομα ξεχωριστή απόδειξη με το PDF του τιμολογίου σου. Περιλαμβάνει όλες τις λεπτομέρειες πληρωμής και μπορεί να χρησιμοποιηθεί για φορολογικούς σκοπούς.\n\nΜπορείς να δεις το ιστορικό των δωρεών σου, να κατεβάσεις τιμολόγια, {interval, select,\n month {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n year {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n other {και να διαχειριστείς μελλοντικές δωρεές}\n} ανά πάσα στιγμή χρησιμοποιώντας αυτόν τον σύνδεσμο:\n\n{manageUrl}\n\nΗ υποστήριξή σου βοηθάει το {product_name} να συνεχίσει να λειτουργεί. Ευχαριστούμε!\n\n– Η ομάδα του {product_name}"
|
||||
"body": "Γεια σου,\n\nΕυχαριστούμε για τη δωρεά σου στο {product_name}! Η {interval, select,\n month {επαναλαμβανόμενη δωρεά}\n year {επαναλαμβανόμενη δωρεά}\n other {εφάπαξ δωρεά}\n} σου έχει {interval, select,\n month {ρυθμιστεί}\n year {ρυθμιστεί}\n other {ολοκληρωθεί}\n} με επιτυχία.\n\nΛεπτομέρειες δωρεάς:\nΠοσό: {amount} {interval, select,\n month {ανά μήνα}\n year {ανά έτος}\n other {}\n}\n\nΗ Stripe θα σου στείλει σύντομα ξεχωριστή απόδειξη με το PDF του τιμολογίου σου. Περιλαμβάνει όλες τις λεπτομέρειες πληρωμής και μπορεί να χρησιμοποιηθεί για φορολογικούς σκοπούς.\n\nΜπορείς να δεις το ιστορικό των δωρεών σου, να κατεβάσεις τιμολόγια, {interval, select,\n month {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n year {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n other {και να διαχειριστείς μελλοντικές δωρεές}\n} ανά πάσα στιγμή χρησιμοποιώντας αυτόν τον σύνδεσμο:\n\n{manageUrl}\n\nΗ υποστήριξή σου βοηθάει το {product_name} να συνεχίσει να λειτουργεί. Ευχαριστούμε!\n\n– Η ομάδα του {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Διαχειρίσου τις δωρεές σου στο {product_name}",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Thank you for your {product_name} donation",
|
||||
"body": "Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {currency} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team"
|
||||
"body": "Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Manage your {product_name} donations",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Gracias por tu donación a {product_name}",
|
||||
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} ha sido {interval, select,\n month {configurada}\n year {configurada}\n other {procesada}\n} con éxito.\n\nDetalles de la donación:\nMonto: {amount} {currency} {interval, select,\n month {por mes}\n year {por año}\n other {}\n}\n\nEn breve, Stripe te enviará por separado un correo electrónico con el recibo y la factura en PDF. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y administrar o cancelar tu suscripción}\n year {y administrar o cancelar tu suscripción}\n other {y administrar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
|
||||
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} ha sido {interval, select,\n month {configurada}\n year {configurada}\n other {procesada}\n} con éxito.\n\nDetalles de la donación:\nMonto: {amount} {interval, select,\n month {por mes}\n year {por año}\n other {}\n}\n\nEn breve, Stripe te enviará por separado un correo electrónico con el recibo y la factura en PDF. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y administrar o cancelar tu suscripción}\n year {y administrar o cancelar tu suscripción}\n other {y administrar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Administra tus donaciones a {product_name}",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Gracias por tu donación a {product_name}",
|
||||
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} se ha {interval, select,\n month {configurado}\n year {configurado}\n other {procesado}\n} correctamente.\n\nDetalles de la donación:\nImporte: {amount} {currency} {interval, select,\n month {al mes}\n year {al año}\n other {}\n}\n\nEn breve, Stripe te enviará por separado un correo electrónico con el recibo y la factura en PDF. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y gestionar o cancelar tu suscripción}\n year {y gestionar o cancelar tu suscripción}\n other {y gestionar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
|
||||
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} se ha {interval, select,\n month {configurado}\n year {configurado}\n other {procesado}\n} correctamente.\n\nDetalles de la donación:\nImporte: {amount} {interval, select,\n month {al mes}\n year {al año}\n other {}\n}\n\nEn breve, Stripe te enviará por separado un correo electrónico con el recibo y la factura en PDF. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y gestionar o cancelar tu suscripción}\n year {y gestionar o cancelar tu suscripción}\n other {y gestionar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Gestiona tus donaciones a {product_name}",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Kiitos {product_name}-lahjoituksestasi",
|
||||
"body": "Hei,\n\nKiitos {product_name}-lahjoituksestasi! {interval, select,\n month {Toistuva lahjoituksesi}\n year {Toistuva lahjoituksesi}\n other {Kertalahjoituksesi}\n} on {interval, select,\n month {käynnistetty}\n year {käynnistetty}\n other {käsitelty}\n} onnistuneesti.\n\nLahjoituksen tiedot:\nSumma: {amount} {currency} {interval, select,\n month {kuukaudessa}\n year {vuodessa}\n other {}\n}\n\nStripe lähettää sinulle pian sähköpostitse erillisen kuitin sekä PDF-laskun. Niissä ovat kaikki maksutiedot, ja niitä voi käyttää verotuksessa.\n\nVoit tarkastella lahjoitushistoriaasi, ladata laskuja {interval, select,\n month {ja hallinnoida tai peruuttaa tilaustasi}\n year {ja hallinnoida tai peruuttaa tilaustasi}\n other {ja hallita tulevia lahjoituksia}\n} milloin tahansa käyttämällä tätä linkkiä:\n\n{manageUrl}\n\nTukesi auttaa pitämään {product_name}-palvelun toiminnassa. Kiitos!\n\n– {product_name}-tiimi"
|
||||
"body": "Hei,\n\nKiitos {product_name}-lahjoituksestasi! {interval, select,\n month {Toistuva lahjoituksesi}\n year {Toistuva lahjoituksesi}\n other {Kertalahjoituksesi}\n} on {interval, select,\n month {käynnistetty}\n year {käynnistetty}\n other {käsitelty}\n} onnistuneesti.\n\nLahjoituksen tiedot:\nSumma: {amount} {interval, select,\n month {kuukaudessa}\n year {vuodessa}\n other {}\n}\n\nStripe lähettää sinulle pian sähköpostitse erillisen kuitin sekä PDF-laskun. Niissä ovat kaikki maksutiedot, ja niitä voi käyttää verotuksessa.\n\nVoit tarkastella lahjoitushistoriaasi, ladata laskuja {interval, select,\n month {ja hallinnoida tai peruuttaa tilaustasi}\n year {ja hallinnoida tai peruuttaa tilaustasi}\n other {ja hallita tulevia lahjoituksia}\n} milloin tahansa käyttämällä tätä linkkiä:\n\n{manageUrl}\n\nTukesi auttaa pitämään {product_name}-palvelun toiminnassa. Kiitos!\n\n– {product_name}-tiimi"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Hallitse {product_name}-lahjoituksiasi",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Merci pour votre don à {product_name}",
|
||||
"body": "Bonjour,\n\nMerci pour votre don à {product_name} ! {interval, select, month {Votre don récurrent a bien été mis en place.} year {Votre don récurrent a bien été mis en place.} other {Votre don ponctuel a bien été traité.}}\n\nDétails du don :\nMontant : {amount} {currency} {interval, select, month {par mois} year {par an} other {}}\n\nStripe vous enverra bientôt un reçu séparé accompagné de votre facture PDF. Ce document contient tous les détails du paiement et peut servir de justificatif fiscal.\n\nVous pouvez consulter l’historique de vos dons, télécharger des factures {interval, select, month {et gérer ou résilier votre abonnement} year {et gérer ou résilier votre abonnement} other {et gérer vos futurs dons}} à tout moment avec ce lien :\n\n{manageUrl}\n\nVotre soutien contribue au fonctionnement de {product_name}. Merci !\n\n– L’équipe {product_name}"
|
||||
"body": "Bonjour,\n\nMerci pour votre don à {product_name} ! {interval, select, month {Votre don récurrent a bien été mis en place.} year {Votre don récurrent a bien été mis en place.} other {Votre don ponctuel a bien été traité.}}\n\nDétails du don :\nMontant : {amount} {interval, select, month {par mois} year {par an} other {}}\n\nStripe vous enverra bientôt un reçu séparé accompagné de votre facture PDF. Ce document contient tous les détails du paiement et peut servir de justificatif fiscal.\n\nVous pouvez consulter l’historique de vos dons, télécharger des factures {interval, select, month {et gérer ou résilier votre abonnement} year {et gérer ou résilier votre abonnement} other {et gérer vos futurs dons}} à tout moment avec ce lien :\n\n{manageUrl}\n\nVotre soutien contribue au fonctionnement de {product_name}. Merci !\n\n– L’équipe {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Gérer vos dons à {product_name}",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "תודה על תרומתכם ל-{product_name}",
|
||||
"body": "שלום,\n\nתודה על תרומתכם ל-{product_name}! ה{interval, select,\n month {תרומה החוזרת}\n year {תרומה החוזרת}\n other {תרומה החד-פעמית}\n} שלכם {interval, select,\n month {הוגדרה}\n year {הוגדרה}\n other {בוצעה}\n} בהצלחה.\n\nפרטי התרומה:\nסכום: {amount} {currency} {interval, select,\n month {לחודש}\n year {לשנה}\n other {}\n}\n\nStripe ישלח לכם בקרוב קבלה נפרדת עם קובץ PDF של החשבונית. הקבלה תכלול את כל פרטי התשלום וניתן יהיה להשתמש בה למטרות מס.\n\nאתם יכולים לצפות בהיסטוריית התרומות שלכם, להוריד חשבוניות, {interval, select,\n month {ולנהל או לבטל את המנוי}\n year {ולנהל או לבטל את המנוי}\n other {ולנהל תרומות עתידיות}\n} בכל עת באמצעות קישור זה:\n\n{manageUrl}\n\nהתמיכה שלכם עוזרת לשמור על פעילות {product_name}. תודה!\n\nצוות {product_name}"
|
||||
"body": "שלום,\n\nתודה על תרומתכם ל-{product_name}! ה{interval, select,\n month {תרומה החוזרת}\n year {תרומה החוזרת}\n other {תרומה החד-פעמית}\n} שלכם {interval, select,\n month {הוגדרה}\n year {הוגדרה}\n other {בוצעה}\n} בהצלחה.\n\nפרטי התרומה:\nסכום: {amount} {interval, select,\n month {לחודש}\n year {לשנה}\n other {}\n}\n\nStripe ישלח לכם בקרוב קבלה נפרדת עם קובץ PDF של החשבונית. הקבלה תכלול את כל פרטי התשלום וניתן יהיה להשתמש בה למטרות מס.\n\nאתם יכולים לצפות בהיסטוריית התרומות שלכם, להוריד חשבוניות, {interval, select,\n month {ולנהל או לבטל את המנוי}\n year {ולנהל או לבטל את המנוי}\n other {ולנהל תרומות עתידיות}\n} בכל עת באמצעות קישור זה:\n\n{manageUrl}\n\nהתמיכה שלכם עוזרת לשמור על פעילות {product_name}. תודה!\n\nצוות {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "ניהול התרומות שלכם ל-{product_name}",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "आपके {product_name} डोनेशन के लिए धन्यवाद",
|
||||
"body": "नमस्ते,\n\n{product_name} को आपके डोनेशन के लिए धन्यवाद! आपका {interval, select,\n month {रिकरिंग डोनेशन}\n year {रिकरिंग डोनेशन}\n other {वन-टाइम डोनेशन}\n} सफलतापूर्वक {interval, select,\n month {सेट अप हो गया है}\n year {सेट अप हो गया है}\n other {प्रोसेस हो गया है}\n}।\n\nडोनेशन का विवरण:\nराशि: {amount} {currency} {interval, select,\n month {प्रति माह}\n year {प्रति वर्ष}\n other {}\n}\n\nStripe आपको जल्द ही आपके इनवॉइस PDF के साथ एक अलग रसीद ईमेल करेगा। इसमें पेमेंट की सारी जानकारी शामिल है और इसे टैक्स के लिए इस्तेमाल किया जा सकता है।\n\nआप इस लिंक से किसी भी समय अपने डोनेशन का इतिहास देख सकते हैं, इनवॉइस डाउनलोड कर सकते हैं, {interval, select,\n month {और अपना सब्सक्रिप्शन मैनेज या कैंसल कर सकते हैं}\n year {और अपना सब्सक्रिप्शन मैनेज या कैंसल कर सकते हैं}\n other {और आगे के डोनेशन मैनेज कर सकते हैं}\n}।\n\n{manageUrl}\n\nआपका सपोर्ट {product_name} को चालू रखने में मदद करता है। धन्यवाद!\n\n– {product_name} टीम"
|
||||
"body": "नमस्ते,\n\n{product_name} को आपके डोनेशन के लिए धन्यवाद! आपका {interval, select,\n month {रिकरिंग डोनेशन}\n year {रिकरिंग डोनेशन}\n other {वन-टाइम डोनेशन}\n} सफलतापूर्वक {interval, select,\n month {सेट अप हो गया है}\n year {सेट अप हो गया है}\n other {प्रोसेस हो गया है}\n}।\n\nडोनेशन का विवरण:\nराशि: {amount} {interval, select,\n month {प्रति माह}\n year {प्रति वर्ष}\n other {}\n}\n\nStripe आपको जल्द ही आपके इनवॉइस PDF के साथ एक अलग रसीद ईमेल करेगा। इसमें पेमेंट की सारी जानकारी शामिल है और इसे टैक्स के लिए इस्तेमाल किया जा सकता है।\n\nआप इस लिंक से किसी भी समय अपने डोनेशन का इतिहास देख सकते हैं, इनवॉइस डाउनलोड कर सकते हैं, {interval, select,\n month {और अपना सब्सक्रिप्शन मैनेज या कैंसल कर सकते हैं}\n year {और अपना सब्सक्रिप्शन मैनेज या कैंसल कर सकते हैं}\n other {और आगे के डोनेशन मैनेज कर सकते हैं}\n}।\n\n{manageUrl}\n\nआपका सपोर्ट {product_name} को चालू रखने में मदद करता है। धन्यवाद!\n\n– {product_name} टीम"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "अपने {product_name} डोनेशन मैनेज करें",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Hvala ti na donaciji za {product_name}",
|
||||
"body": "Pozdrav,\n\nHvala ti na donaciji za {product_name}! Tvoja {interval, select,\n month {redovita donacija}\n year {redovita donacija}\n other {jednokratna donacija}\n} uspješno je {interval, select,\n month {postavljena}\n year {postavljena}\n other {obrađena}\n}.\n\nDetalji donacije:\nIznos: {amount} {currency} {interval, select,\n month {mjesečno}\n year {godišnje}\n other {}\n}\n\nStripe će ti uskoro poslati zasebnu potvrdu s PDF-om računa. Ona sadrži sve detalje plaćanja i možeš je koristiti u porezne svrhe.\n\nMožeš pregledati povijest donacija, preuzeti račune {interval, select,\n month {i upravljati pretplatom ili je otkazati}\n year {i upravljati pretplatom ili je otkazati}\n other {i upravljati budućim donacijama}\n} u bilo kojem trenutku putem ove poveznice:\n\n{manageUrl}\n\nTvoja podrška pomaže da {product_name} nastavi raditi. Hvala ti!\n\n– Tim {product_name}a"
|
||||
"body": "Pozdrav,\n\nHvala ti na donaciji za {product_name}! Tvoja {interval, select,\n month {redovita donacija}\n year {redovita donacija}\n other {jednokratna donacija}\n} uspješno je {interval, select,\n month {postavljena}\n year {postavljena}\n other {obrađena}\n}.\n\nDetalji donacije:\nIznos: {amount} {interval, select,\n month {mjesečno}\n year {godišnje}\n other {}\n}\n\nStripe će ti uskoro poslati zasebnu potvrdu s PDF-om računa. Ona sadrži sve detalje plaćanja i možeš je koristiti u porezne svrhe.\n\nMožeš pregledati povijest donacija, preuzeti račune {interval, select,\n month {i upravljati pretplatom ili je otkazati}\n year {i upravljati pretplatom ili je otkazati}\n other {i upravljati budućim donacijama}\n} u bilo kojem trenutku putem ove poveznice:\n\n{manageUrl}\n\nTvoja podrška pomaže da {product_name} nastavi raditi. Hvala ti!\n\n– Tim {product_name}a"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Upravljanje donacijama za {product_name}",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Köszönjük, hogy adományoddal támogatod a {product_name} működését",
|
||||
"body": "Szia!\n\nKöszönjük, hogy adományoddal támogatod a {product_name} működését! {interval, select,\n month {Sikeresen beállítottuk a rendszeres adományodat.}\n year {Sikeresen beállítottuk a rendszeres adományodat.}\n other {Sikeresen feldolgoztuk az egyszeri adományodat.}\n}\n\nAz adomány részletei:\nÖsszeg: {amount} {currency} {interval, select,\n month {havonta}\n year {évente}\n other {}\n}\n\nA Stripe hamarosan külön e-mailben küld nyugtát és egy PDF-számlát. Ezek tartalmazzák az összes fizetési adatot, és adózási célokra is használhatók.\n\nAz alábbi linken bármikor megtekintheted korábbi adományaidat, letöltheted a számlákat, {interval, select,\n month {valamint kezelheted vagy lemondhatod az előfizetésedet}\n year {valamint kezelheted vagy lemondhatod az előfizetésedet}\n other {valamint kezelheted a jövőbeli adományaidat}\n}:\n\n{manageUrl}\n\nTámogatásod segít fenntartani a {product_name} működését. Köszönjük!\n\n– {product_name} csapata"
|
||||
"body": "Szia!\n\nKöszönjük, hogy adományoddal támogatod a {product_name} működését! {interval, select,\n month {Sikeresen beállítottuk a rendszeres adományodat.}\n year {Sikeresen beállítottuk a rendszeres adományodat.}\n other {Sikeresen feldolgoztuk az egyszeri adományodat.}\n}\n\nAz adomány részletei:\nÖsszeg: {amount} {interval, select,\n month {havonta}\n year {évente}\n other {}\n}\n\nA Stripe hamarosan külön e-mailben küld nyugtát és egy PDF-számlát. Ezek tartalmazzák az összes fizetési adatot, és adózási célokra is használhatók.\n\nAz alábbi linken bármikor megtekintheted korábbi adományaidat, letöltheted a számlákat, {interval, select,\n month {valamint kezelheted vagy lemondhatod az előfizetésedet}\n year {valamint kezelheted vagy lemondhatod az előfizetésedet}\n other {valamint kezelheted a jövőbeli adományaidat}\n}:\n\n{manageUrl}\n\nTámogatásod segít fenntartani a {product_name} működését. Köszönjük!\n\n– {product_name} csapata"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "A {product_name} működését támogató adományaid kezelése",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Terima kasih atas donasimu untuk {product_name}",
|
||||
"body": "Halo,\n\nTerima kasih atas donasimu untuk {product_name}! Donasi {interval, select,\n month {berulang}\n year {berulang}\n other {satu kali}\n} kamu telah berhasil {interval, select,\n month {diatur}\n year {diatur}\n other {diproses}\n}.\n\nDetail donasi:\nJumlah: {amount} {currency} {interval, select,\n month {per bulan}\n year {per tahun}\n other {}\n}\n\nStripe akan segera mengirimi kamu email tanda terima terpisah beserta PDF fakturmu. Ini mencakup semua detail pembayaran dan bisa dipakai untuk keperluan pajak.\n\nKamu bisa melihat riwayat donasimu, mengunduh faktur, {interval, select,\n month {dan mengelola atau membatalkan langgananmu}\n year {dan mengelola atau membatalkan langgananmu}\n other {dan mengelola donasi di masa mendatang}\n} kapan saja lewat tautan ini:\n\n{manageUrl}\n\nDukunganmu membantu {product_name} tetap berjalan. Terima kasih!\n\n– Tim {product_name}"
|
||||
"body": "Halo,\n\nTerima kasih atas donasimu untuk {product_name}! Donasi {interval, select,\n month {berulang}\n year {berulang}\n other {satu kali}\n} kamu telah berhasil {interval, select,\n month {diatur}\n year {diatur}\n other {diproses}\n}.\n\nDetail donasi:\nJumlah: {amount} {interval, select,\n month {per bulan}\n year {per tahun}\n other {}\n}\n\nStripe akan segera mengirimi kamu email tanda terima terpisah beserta PDF fakturmu. Ini mencakup semua detail pembayaran dan bisa dipakai untuk keperluan pajak.\n\nKamu bisa melihat riwayat donasimu, mengunduh faktur, {interval, select,\n month {dan mengelola atau membatalkan langgananmu}\n year {dan mengelola atau membatalkan langgananmu}\n other {dan mengelola donasi di masa mendatang}\n} kapan saja lewat tautan ini:\n\n{manageUrl}\n\nDukunganmu membantu {product_name} tetap berjalan. Terima kasih!\n\n– Tim {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Kelola donasimu untuk {product_name}",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Grazie per la tua donazione a {product_name}",
|
||||
"body": "Ciao,\n\nGrazie per la tua donazione a {product_name}! La tua {interval, select,\n month {donazione ricorrente}\n year {donazione ricorrente}\n other {donazione una tantum}\n} è stata {interval, select,\n month {attivata}\n year {attivata}\n other {elaborata}\n} con successo.\n\nDettagli della donazione:\nImporto: {amount} {currency} {interval, select,\n month {al mese}\n year {all'anno}\n other {}\n}\n\nStripe ti invierà a breve una ricevuta separata con la tua fattura in PDF. Questa include tutti i dettagli del pagamento e può essere utilizzata a fini fiscali.\n\nPuoi visualizzare la cronologia delle tue donazioni, scaricare le fatture {interval, select,\n month {e gestire o disdire il tuo abbonamento}\n year {e gestire o disdire il tuo abbonamento}\n other {e gestire le future donazioni}\n} in qualsiasi momento utilizzando questo link:\n\n{manageUrl}\n\nIl tuo supporto aiuta a mantenere {product_name} in funzione. Grazie!\n\n– Il team di {product_name}"
|
||||
"body": "Ciao,\n\nGrazie per la tua donazione a {product_name}! La tua {interval, select,\n month {donazione ricorrente}\n year {donazione ricorrente}\n other {donazione una tantum}\n} è stata {interval, select,\n month {attivata}\n year {attivata}\n other {elaborata}\n} con successo.\n\nDettagli della donazione:\nImporto: {amount} {interval, select,\n month {al mese}\n year {all'anno}\n other {}\n}\n\nStripe ti invierà a breve una ricevuta separata con la tua fattura in PDF. Questa include tutti i dettagli del pagamento e può essere utilizzata a fini fiscali.\n\nPuoi visualizzare la cronologia delle tue donazioni, scaricare le fatture {interval, select,\n month {e gestire o disdire il tuo abbonamento}\n year {e gestire o disdire il tuo abbonamento}\n other {e gestire le future donazioni}\n} in qualsiasi momento utilizzando questo link:\n\n{manageUrl}\n\nIl tuo supporto aiuta a mantenere {product_name} in funzione. Grazie!\n\n– Il team di {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Gestisci le tue donazioni a {product_name}",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "{product_name}へのご寄付ありがとうございます",
|
||||
"body": "こんにちは。\n\n{product_name}へのご寄付ありがとうございます。{interval, select,\n month {定期寄付}\n year {定期寄付}\n other {1回のみの寄付}\n}の{interval, select,\n month {設定}\n year {設定}\n other {処理}\n}が完了しました。\n\n寄付の詳細:\n金額: {amount} {currency}{interval, select,\n month {(毎月)}\n year {(毎年)}\n other {}\n}\n\nStripeから、請求書PDF付きの領収書が別途メールで送信されます。これにはすべての支払い詳細が含まれており、税務目的にご利用いただけます。\n\n以下のリンクから、いつでも寄付履歴の表示、請求書のダウンロード、{interval, select,\n month {およびサブスクリプションの管理またはキャンセル}\n year {およびサブスクリプションの管理またはキャンセル}\n other {および今後の寄付の管理}\n}が可能です。\n\n{manageUrl}\n\n皆様のご支援が{product_name}の運営を支えています。ありがとうございます。\n\n– {product_name}チーム"
|
||||
"body": "こんにちは。\n\n{product_name}へのご寄付ありがとうございます。{interval, select,\n month {定期寄付}\n year {定期寄付}\n other {1回のみの寄付}\n}の{interval, select,\n month {設定}\n year {設定}\n other {処理}\n}が完了しました。\n\n寄付の詳細:\n金額: {amount}{interval, select,\n month {(毎月)}\n year {(毎年)}\n other {}\n}\n\nStripeから、請求書PDF付きの領収書が別途メールで送信されます。これにはすべての支払い詳細が含まれており、税務目的にご利用いただけます。\n\n以下のリンクから、いつでも寄付履歴の表示、請求書のダウンロード、{interval, select,\n month {およびサブスクリプションの管理またはキャンセル}\n year {およびサブスクリプションの管理またはキャンセル}\n other {および今後の寄付の管理}\n}が可能です。\n\n{manageUrl}\n\n皆様のご支援が{product_name}の運営を支えています。ありがとうございます。\n\n– {product_name}チーム"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "{product_name}への寄付を管理",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "{product_name} 후원에 감사드려요",
|
||||
"body": "안녕하세요,\n\n{product_name}에 후원해 주셔서 감사해요! 회원님의 {interval, select,\n month {정기 후원}\n year {정기 후원}\n other {일회성 후원}\n}이 성공적으로 {interval, select,\n month {설정되었어요}\n year {설정되었어요}\n other {처리되었어요}\n}.\n\n후원 상세 정보:\n금액: {amount} {currency} {interval, select,\n month {매월}\n year {매년}\n other {}\n}\n\nStripe에서 곧 인보이스 PDF가 포함된 별도의 영수증을 이메일로 보내드릴 예정이에요. 이 영수증에는 모든 결제 세부 정보가 포함되어 있으며, 세금 목적으로 활용할 수 있어요.\n\n다음 링크를 통해 언제든지 후원 내역을 확인하고, 인보이스를 다운로드하고, {interval, select,\n month {구독을 관리하거나 취소할 수 있어요}\n year {구독을 관리하거나 취소할 수 있어요}\n other {향후 후원을 관리할 수 있어요}\n}:\n\n{manageUrl}\n\n회원님의 후원은 {product_name}를 계속 운영하는 데 큰 도움이 돼요. 진심으로 감사해요!\n\n– {product_name} 팀"
|
||||
"body": "안녕하세요,\n\n{product_name}에 후원해 주셔서 감사해요! 회원님의 {interval, select,\n month {정기 후원}\n year {정기 후원}\n other {일회성 후원}\n}이 성공적으로 {interval, select,\n month {설정되었어요}\n year {설정되었어요}\n other {처리되었어요}\n}.\n\n후원 상세 정보:\n금액: {amount} {interval, select,\n month {매월}\n year {매년}\n other {}\n}\n\nStripe에서 곧 인보이스 PDF가 포함된 별도의 영수증을 이메일로 보내드릴 예정이에요. 이 영수증에는 모든 결제 세부 정보가 포함되어 있으며, 세금 목적으로 활용할 수 있어요.\n\n다음 링크를 통해 언제든지 후원 내역을 확인하고, 인보이스를 다운로드하고, {interval, select,\n month {구독을 관리하거나 취소할 수 있어요}\n year {구독을 관리하거나 취소할 수 있어요}\n other {향후 후원을 관리할 수 있어요}\n}:\n\n{manageUrl}\n\n회원님의 후원은 {product_name}를 계속 운영하는 데 큰 도움이 돼요. 진심으로 감사해요!\n\n– {product_name} 팀"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "{product_name} 후원 내역 관리",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Dėkojame už tavo {product_name} paramą",
|
||||
"body": "Sveiki,\n\nDėkojame už tavo paramą {product_name}! Tavo {interval, select,\n month {pasikartojanti parama}\n year {pasikartojanti parama}\n other {vienkartinė parama}\n} sėkmingai {interval, select,\n month {nustatyta}\n year {nustatyta}\n other {apdorota}\n}.\n\nParamos informacija:\nSuma: {amount} {currency} {interval, select,\n month {per mėnesį}\n year {per metus}\n other {}\n}\n\nStripe netrukus atsiųs tau atskirą kvitą su sąskaitos faktūros PDF. Jame nurodyta visa mokėjimo informacija ir jį galima naudoti mokesčių tikslais.\n\nBet kuriuo metu šia nuoroda gali peržiūrėti savo paramos istoriją, atsisiųsti sąskaitas faktūras {interval, select,\n month {ir valdyti arba atšaukti savo prenumeratą}\n year {ir valdyti arba atšaukti savo prenumeratą}\n other {ir valdyti būsimą paramą}\n}:\n\n{manageUrl}\n\nTavo parama padeda {product_name} veikti. Ačiū!\n\n– {product_name} komanda"
|
||||
"body": "Sveiki,\n\nDėkojame už tavo paramą {product_name}! Tavo {interval, select,\n month {pasikartojanti parama}\n year {pasikartojanti parama}\n other {vienkartinė parama}\n} sėkmingai {interval, select,\n month {nustatyta}\n year {nustatyta}\n other {apdorota}\n}.\n\nParamos informacija:\nSuma: {amount} {interval, select,\n month {per mėnesį}\n year {per metus}\n other {}\n}\n\nStripe netrukus atsiųs tau atskirą kvitą su sąskaitos faktūros PDF. Jame nurodyta visa mokėjimo informacija ir jį galima naudoti mokesčių tikslais.\n\nBet kuriuo metu šia nuoroda gali peržiūrėti savo paramos istoriją, atsisiųsti sąskaitas faktūras {interval, select,\n month {ir valdyti arba atšaukti savo prenumeratą}\n year {ir valdyti arba atšaukti savo prenumeratą}\n other {ir valdyti būsimą paramą}\n}:\n\n{manageUrl}\n\nTavo parama padeda {product_name} veikti. Ačiū!\n\n– {product_name} komanda"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Tvarkyk savo {product_name} paramą",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Bedankt voor je {product_name}-donatie",
|
||||
"body": "Hallo,\n\nBedankt voor je donatie aan {product_name}! Je {interval, select,\n month {terugkerende donatie}\n year {terugkerende donatie}\n other {eenmalige donatie}\n} is succesvol {interval, select,\n month {ingesteld}\n year {ingesteld}\n other {verwerkt}\n}.\n\nDonatiegegevens:\nBedrag: {amount} {currency} {interval, select,\n month {per maand}\n year {per jaar}\n other {}\n}\n\nStripe stuurt je binnenkort per e-mail een apart betalingsbewijs met je factuur als pdf. Dit bevat alle betalingsgegevens en kan worden gebruikt voor belastingdoeleinden.\n\nJe kunt op elk moment je donatiegeschiedenis bekijken, facturen downloaden en {interval, select,\n month {je abonnement beheren of opzeggen}\n year {je abonnement beheren of opzeggen}\n other {toekomstige donaties beheren}\n} via deze link:\n\n{manageUrl}\n\nJe steun helpt {product_name} draaiende te houden. Bedankt!\n\n– Het team van {product_name}"
|
||||
"body": "Hallo,\n\nBedankt voor je donatie aan {product_name}! Je {interval, select,\n month {terugkerende donatie}\n year {terugkerende donatie}\n other {eenmalige donatie}\n} is succesvol {interval, select,\n month {ingesteld}\n year {ingesteld}\n other {verwerkt}\n}.\n\nDonatiegegevens:\nBedrag: {amount} {interval, select,\n month {per maand}\n year {per jaar}\n other {}\n}\n\nStripe stuurt je binnenkort per e-mail een apart betalingsbewijs met je factuur als pdf. Dit bevat alle betalingsgegevens en kan worden gebruikt voor belastingdoeleinden.\n\nJe kunt op elk moment je donatiegeschiedenis bekijken, facturen downloaden en {interval, select,\n month {je abonnement beheren of opzeggen}\n year {je abonnement beheren of opzeggen}\n other {toekomstige donaties beheren}\n} via deze link:\n\n{manageUrl}\n\nJe steun helpt {product_name} draaiende te houden. Bedankt!\n\n– Het team van {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Beheer je {product_name}-donaties",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Takk for bidraget ditt til {product_name}",
|
||||
"body": "Hei,\n\nTakk for bidraget ditt til {product_name}! {interval, select,\n month {Det faste bidraget ditt er opprettet.}\n year {Det faste bidraget ditt er opprettet.}\n other {Engangsbidraget ditt er behandlet.}\n}\n\nDetaljer om bidraget:\nBeløp: {amount} {currency} {interval, select,\n month {per måned}\n year {per år}\n other {}\n}\n\nStripe sender deg snart en egen kvittering med fakturaen som PDF. Kvitteringen inneholder alle betalingsdetaljer og kan brukes til skatteformål.\n\nDu kan når som helst se bidragshistorikken din, laste ned fakturaer og {interval, select,\n month {administrere eller si opp abonnementet ditt}\n year {administrere eller si opp abonnementet ditt}\n other {administrere framtidige bidrag}\n} via denne lenken:\n\n{manageUrl}\n\nStøtten din bidrar til å holde {product_name} i gang. Takk!\n\n– {product_name}-teamet"
|
||||
"body": "Hei,\n\nTakk for bidraget ditt til {product_name}! {interval, select,\n month {Det faste bidraget ditt er opprettet.}\n year {Det faste bidraget ditt er opprettet.}\n other {Engangsbidraget ditt er behandlet.}\n}\n\nDetaljer om bidraget:\nBeløp: {amount} {interval, select,\n month {per måned}\n year {per år}\n other {}\n}\n\nStripe sender deg snart en egen kvittering med fakturaen som PDF. Kvitteringen inneholder alle betalingsdetaljer og kan brukes til skatteformål.\n\nDu kan når som helst se bidragshistorikken din, laste ned fakturaer og {interval, select,\n month {administrere eller si opp abonnementet ditt}\n year {administrere eller si opp abonnementet ditt}\n other {administrere framtidige bidrag}\n} via denne lenken:\n\n{manageUrl}\n\nStøtten din bidrar til å holde {product_name} i gang. Takk!\n\n– {product_name}-teamet"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Administrer {product_name}-bidragene dine",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Dziękujemy za darowiznę dla {product_name}",
|
||||
"body": "Witaj,\n\nDziękujemy za darowiznę dla {product_name}! Twoja {interval, select,\n month {cykliczna darowizna}\n year {cykliczna darowizna}\n other {jednorazowa darowizna}\n} została {interval, select,\n month {uruchomiona}\n year {uruchomiona}\n other {przetworzona}\n} pomyślnie.\n\nSzczegóły darowizny:\nKwota: {amount} {currency} {interval, select,\n month {miesięcznie}\n year {rocznie}\n other {}\n}\n\nStripe wkrótce wyśle Ci osobną wiadomość e-mail z potwierdzeniem i fakturą w formacie PDF. Potwierdzenie zawiera wszystkie szczegóły płatności i może służyć do celów podatkowych.\n\nMożesz przeglądać historię swoich darowizn, pobierać faktury {interval, select,\n month {oraz zarządzać subskrypcją lub ją anulować}\n year {oraz zarządzać subskrypcją lub ją anulować}\n other {oraz zarządzać przyszłymi darowiznami}\n} w dowolnym momencie, korzystając z tego linku:\n\n{manageUrl}\n\nTwoje wsparcie pomaga utrzymać {product_name}. Dziękujemy!\n\n– Zespół {product_name}"
|
||||
"body": "Witaj,\n\nDziękujemy za darowiznę dla {product_name}! Twoja {interval, select,\n month {cykliczna darowizna}\n year {cykliczna darowizna}\n other {jednorazowa darowizna}\n} została {interval, select,\n month {uruchomiona}\n year {uruchomiona}\n other {przetworzona}\n} pomyślnie.\n\nSzczegóły darowizny:\nKwota: {amount} {interval, select,\n month {miesięcznie}\n year {rocznie}\n other {}\n}\n\nStripe wkrótce wyśle Ci osobną wiadomość e-mail z potwierdzeniem i fakturą w formacie PDF. Potwierdzenie zawiera wszystkie szczegóły płatności i może służyć do celów podatkowych.\n\nMożesz przeglądać historię swoich darowizn, pobierać faktury {interval, select,\n month {oraz zarządzać subskrypcją lub ją anulować}\n year {oraz zarządzać subskrypcją lub ją anulować}\n other {oraz zarządzać przyszłymi darowiznami}\n} w dowolnym momencie, korzystając z tego linku:\n\n{manageUrl}\n\nTwoje wsparcie pomaga utrzymać {product_name}. Dziękujemy!\n\n– Zespół {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Zarządzaj darowiznami dla {product_name}",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Agradecemos pela sua doação ao {product_name}",
|
||||
"body": "Olá,\n\nAgradecemos pela sua doação ao {product_name}! Sua {interval, select,\n month {doação recorrente}\n year {doação recorrente}\n other {doação única}\n} foi {interval, select,\n month {configurada}\n year {configurada}\n other {processada}\n} com sucesso.\n\nDetalhes da doação:\nValor: {amount} {currency} {interval, select,\n month {por mês}\n year {por ano}\n other {}\n}\n\nA Stripe enviará um recibo separado com sua fatura em PDF em breve. Isso inclui todos os detalhes de pagamento e pode ser usado para fins fiscais.\n\nVocê pode visualizar seu histórico de doações, baixar faturas {interval, select,\n month {e gerenciar ou cancelar sua assinatura}\n year {e gerenciar ou cancelar sua assinatura}\n other {e gerenciar futuras doações}\n} a qualquer momento usando este link:\n\n{manageUrl}\n\nSeu apoio ajuda a manter o {product_name} funcionando. Agradecemos!\n\n– Equipe do {product_name}"
|
||||
"body": "Olá,\n\nAgradecemos pela sua doação ao {product_name}! Sua {interval, select,\n month {doação recorrente}\n year {doação recorrente}\n other {doação única}\n} foi {interval, select,\n month {configurada}\n year {configurada}\n other {processada}\n} com sucesso.\n\nDetalhes da doação:\nValor: {amount} {interval, select,\n month {por mês}\n year {por ano}\n other {}\n}\n\nA Stripe enviará um recibo separado com sua fatura em PDF em breve. Isso inclui todos os detalhes de pagamento e pode ser usado para fins fiscais.\n\nVocê pode visualizar seu histórico de doações, baixar faturas {interval, select,\n month {e gerenciar ou cancelar sua assinatura}\n year {e gerenciar ou cancelar sua assinatura}\n other {e gerenciar futuras doações}\n} a qualquer momento usando este link:\n\n{manageUrl}\n\nSeu apoio ajuda a manter o {product_name} funcionando. Agradecemos!\n\n– Equipe do {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Gerencie suas doações ao {product_name}",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Îți mulțumim pentru donația către {product_name}",
|
||||
"body": "Salut,\n\nÎți mulțumim pentru donația ta către {product_name}! {interval, select,\n month {Donația ta recurentă}\n year {Donația ta recurentă}\n other {Donația ta unică}\n} a fost {interval, select,\n month {configurată}\n year {configurată}\n other {procesată}\n} cu succes.\n\nDetalii donație:\nSumă: {amount} {currency} {interval, select,\n month {pe lună}\n year {pe an}\n other {}\n}\n\nStripe îți va trimite în scurt timp un e-mail separat cu chitanța și factura ta în format PDF. Acestea includ toate detaliile plății și pot fi folosite în scopuri fiscale.\n\nPoți vizualiza istoricul donațiilor, descărca facturi {interval, select,\n month {și gestiona sau anula abonamentul}\n year {și gestiona sau anula abonamentul}\n other {și gestiona donațiile viitoare}\n} în orice moment folosind acest link:\n\n{manageUrl}\n\nSprijinul tău ne ajută să menținem {product_name} în funcțiune. Mulțumim!\n\n– Echipa {product_name}"
|
||||
"body": "Salut,\n\nÎți mulțumim pentru donația ta către {product_name}! {interval, select,\n month {Donația ta recurentă}\n year {Donația ta recurentă}\n other {Donația ta unică}\n} a fost {interval, select,\n month {configurată}\n year {configurată}\n other {procesată}\n} cu succes.\n\nDetalii donație:\nSumă: {amount} {interval, select,\n month {pe lună}\n year {pe an}\n other {}\n}\n\nStripe îți va trimite în scurt timp un e-mail separat cu chitanța și factura ta în format PDF. Acestea includ toate detaliile plății și pot fi folosite în scopuri fiscale.\n\nPoți vizualiza istoricul donațiilor, descărca facturi {interval, select,\n month {și gestiona sau anula abonamentul}\n year {și gestiona sau anula abonamentul}\n other {și gestiona donațiile viitoare}\n} în orice moment folosind acest link:\n\n{manageUrl}\n\nSprijinul tău ne ajută să menținem {product_name} în funcțiune. Mulțumim!\n\n– Echipa {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Gestionează-ți donațiile către {product_name}",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Спасибо за твоё пожертвование для {product_name}",
|
||||
"body": "Привет,\n\nСпасибо за твоё пожертвование для {product_name}! Твоё {interval, select,\n month {регулярное пожертвование}\n year {регулярное пожертвование}\n other {разовое пожертвование}\n} успешно {interval, select,\n month {настроено}\n year {настроено}\n other {обработано}\n}.\n\nДетали пожертвования:\nСумма: {amount} {currency} {interval, select,\n month {в месяц}\n year {в год}\n other {}\n}\n\nStripe вскоре отправит тебе отдельную квитанцию со счётом в формате PDF. В ней есть все платёжные данные, и её можно использовать для налоговых целей.\n\nТы можешь просматривать историю своих пожертвований, скачивать счета, {interval, select,\n month {а также управлять подпиской или отменить её}\n year {а также управлять подпиской или отменить её}\n other {и управлять будущими пожертвованиями}\n} в любое время по этой ссылке:\n\n{manageUrl}\n\nТвоя поддержка помогает {product_name} работать. Спасибо!\n\n– Команда {product_name}"
|
||||
"body": "Привет,\n\nСпасибо за твоё пожертвование для {product_name}! Твоё {interval, select,\n month {регулярное пожертвование}\n year {регулярное пожертвование}\n other {разовое пожертвование}\n} успешно {interval, select,\n month {настроено}\n year {настроено}\n other {обработано}\n}.\n\nДетали пожертвования:\nСумма: {amount} {interval, select,\n month {в месяц}\n year {в год}\n other {}\n}\n\nStripe вскоре отправит тебе отдельную квитанцию со счётом в формате PDF. В ней есть все платёжные данные, и её можно использовать для налоговых целей.\n\nТы можешь просматривать историю своих пожертвований, скачивать счета, {interval, select,\n month {а также управлять подпиской или отменить её}\n year {а также управлять подпиской или отменить её}\n other {и управлять будущими пожертвованиями}\n} в любое время по этой ссылке:\n\n{manageUrl}\n\nТвоя поддержка помогает {product_name} работать. Спасибо!\n\n– Команда {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Управляй своими пожертвованиями для {product_name}",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Tack för din donation till {product_name}",
|
||||
"body": "Hej,\n\nTack för din donation till {product_name}! {interval, select,\n month {Din återkommande donation har registrerats.}\n year {Din återkommande donation har registrerats.}\n other {Din engångsdonation har genomförts.}\n}\n\nDonationsuppgifter:\nBelopp: {amount} {currency} {interval, select,\n month {per månad}\n year {per år}\n other {}\n}\n\nStripe skickar snart ett separat kvitto med din faktura i PDF-format via e-post. Där finns alla betalningsuppgifter, och underlaget kan användas för skatteändamål.\n\nDu kan se din donationshistorik, ladda ner fakturor {interval, select,\n month {och hantera eller avsluta din återkommande donation}\n year {och hantera eller avsluta din återkommande donation}\n other {och hantera framtida donationer}\n} när som helst via den här länken:\n\n{manageUrl}\n\nDitt stöd hjälper till att hålla {product_name} igång. Tack!\n\n– {product_name}-teamet"
|
||||
"body": "Hej,\n\nTack för din donation till {product_name}! {interval, select,\n month {Din återkommande donation har registrerats.}\n year {Din återkommande donation har registrerats.}\n other {Din engångsdonation har genomförts.}\n}\n\nDonationsuppgifter:\nBelopp: {amount} {interval, select,\n month {per månad}\n year {per år}\n other {}\n}\n\nStripe skickar snart ett separat kvitto med din faktura i PDF-format via e-post. Där finns alla betalningsuppgifter, och underlaget kan användas för skatteändamål.\n\nDu kan se din donationshistorik, ladda ner fakturor {interval, select,\n month {och hantera eller avsluta din återkommande donation}\n year {och hantera eller avsluta din återkommande donation}\n other {och hantera framtida donationer}\n} när som helst via den här länken:\n\n{manageUrl}\n\nDitt stöd hjälper till att hålla {product_name} igång. Tack!\n\n– {product_name}-teamet"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Hantera dina donationer till {product_name}",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "ขอบคุณสำหรับการบริจาคให้ {product_name}",
|
||||
"body": "สวัสดี,\n\nขอบคุณสำหรับการบริจาคให้ {product_name}! {interval, select,\n month {การบริจาคแบบต่อเนื่อง}\n year {การบริจาคแบบต่อเนื่อง}\n other {การบริจาคครั้งเดียว}\n}ของคุณได้รับการ{interval, select,\n month {ตั้งค่า}\n year {ตั้งค่า}\n other {ดำเนินการ}\n}เรียบร้อยแล้ว\n\nรายละเอียดการบริจาค:\nจำนวนเงิน: {amount} {currency} {interval, select,\n month {ต่อเดือน}\n year {ต่อปี}\n other {}\n}\n\nStripe จะส่งอีเมลใบเสร็จแยกต่างหากพร้อมไฟล์ PDF ใบแจ้งหนี้ให้คุณในไม่ช้า ใบเสร็จนี้มีรายละเอียดการชำระเงินทั้งหมดและใช้สำหรับวัตถุประสงค์ทางภาษีได้\n\nคุณสามารถดูประวัติการบริจาค ดาวน์โหลดใบแจ้งหนี้ {interval, select,\n month {และจัดการหรือยกเลิกการสมัครสมาชิกของคุณ}\n year {และจัดการหรือยกเลิกการสมัครสมาชิกของคุณ}\n other {และจัดการการบริจาคในอนาคต}\n} ได้ตลอดเวลาโดยใช้ลิงก์นี้:\n\n{manageUrl}\n\nการสนับสนุนของคุณช่วยให้ {product_name} ดำเนินต่อไปได้ ขอบคุณ!\n\n– ทีม {product_name}"
|
||||
"body": "สวัสดี,\n\nขอบคุณสำหรับการบริจาคให้ {product_name}! {interval, select,\n month {การบริจาคแบบต่อเนื่อง}\n year {การบริจาคแบบต่อเนื่อง}\n other {การบริจาคครั้งเดียว}\n}ของคุณได้รับการ{interval, select,\n month {ตั้งค่า}\n year {ตั้งค่า}\n other {ดำเนินการ}\n}เรียบร้อยแล้ว\n\nรายละเอียดการบริจาค:\nจำนวนเงิน: {amount} {interval, select,\n month {ต่อเดือน}\n year {ต่อปี}\n other {}\n}\n\nStripe จะส่งอีเมลใบเสร็จแยกต่างหากพร้อมไฟล์ PDF ใบแจ้งหนี้ให้คุณในไม่ช้า ใบเสร็จนี้มีรายละเอียดการชำระเงินทั้งหมดและใช้สำหรับวัตถุประสงค์ทางภาษีได้\n\nคุณสามารถดูประวัติการบริจาค ดาวน์โหลดใบแจ้งหนี้ {interval, select,\n month {และจัดการหรือยกเลิกการสมัครสมาชิกของคุณ}\n year {และจัดการหรือยกเลิกการสมัครสมาชิกของคุณ}\n other {และจัดการการบริจาคในอนาคต}\n} ได้ตลอดเวลาโดยใช้ลิงก์นี้:\n\n{manageUrl}\n\nการสนับสนุนของคุณช่วยให้ {product_name} ดำเนินต่อไปได้ ขอบคุณ!\n\n– ทีม {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "จัดการการบริจาคให้ {product_name}",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "{product_name}'a yaptığın bağış için teşekkürler",
|
||||
"body": "Merhaba,\n\n{product_name}'a yaptığın bağış için teşekkürler! {interval, select,\n month {tekrarlayan bağışın}\n year {tekrarlayan bağışın}\n other {tek seferlik bağışın}\n} başarıyla {interval, select,\n month {ayarlandı}\n year {ayarlandı}\n other {işlendi}\n}.\n\nBağış detayları:\nTutar: {amount} {currency} {interval, select,\n month {aylık}\n year {yıllık}\n other {}\n}\n\nStripe, kısa süre içinde fatura PDF'ini içeren ayrı bir makbuzu sana e-posta ile gönderecektir. Bu, tüm ödeme detaylarını içerir ve vergi amaçlı kullanılabilir.\n\nBu bağlantıyı kullanarak istediğin zaman bağış geçmişini görüntüleyebilir, faturaları indirebilir, {interval, select,\n month {ve aboneliğini yönetebilir veya iptal edebilirsin}\n year {ve aboneliğini yönetebilir veya iptal edebilirsin}\n other {ve gelecekteki bağışlarını yönetebilirsin}\n}:\n\n{manageUrl}\n\nDesteğin {product_name}'ın çalışmaya devam etmesine yardımcı oluyor. Teşekkürler!\n\n– {product_name} Ekibi"
|
||||
"body": "Merhaba,\n\n{product_name}'a yaptığın bağış için teşekkürler! {interval, select,\n month {tekrarlayan bağışın}\n year {tekrarlayan bağışın}\n other {tek seferlik bağışın}\n} başarıyla {interval, select,\n month {ayarlandı}\n year {ayarlandı}\n other {işlendi}\n}.\n\nBağış detayları:\nTutar: {amount} {interval, select,\n month {aylık}\n year {yıllık}\n other {}\n}\n\nStripe, kısa süre içinde fatura PDF'ini içeren ayrı bir makbuzu sana e-posta ile gönderecektir. Bu, tüm ödeme detaylarını içerir ve vergi amaçlı kullanılabilir.\n\nBu bağlantıyı kullanarak istediğin zaman bağış geçmişini görüntüleyebilir, faturaları indirebilir, {interval, select,\n month {ve aboneliğini yönetebilir veya iptal edebilirsin}\n year {ve aboneliğini yönetebilir veya iptal edebilirsin}\n other {ve gelecekteki bağışlarını yönetebilirsin}\n}:\n\n{manageUrl}\n\nDesteğin {product_name}'ın çalışmaya devam etmesine yardımcı oluyor. Teşekkürler!\n\n– {product_name} Ekibi"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "{product_name} bağışlarını yönet",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Дякуємо за твою пожертву для {product_name}",
|
||||
"body": "Привіт,\n\nДякуємо за твою пожертву для {product_name}! Твою {interval, select,\n month {регулярну пожертву}\n year {регулярну пожертву}\n other {одноразову пожертву}\n} успішно {interval, select,\n month {налаштовано}\n year {налаштовано}\n other {оброблено}\n}.\n\nДеталі пожертви:\nСума: {amount} {currency} {interval, select,\n month {на місяць}\n year {на рік}\n other {}\n}\n\nStripe незабаром надішле тобі окрему квитанцію з рахунком у форматі PDF. Вона містить усі платіжні дані, і її можна використати для податкових цілей.\n\nТи можеш переглядати історію своїх пожертв, завантажувати рахунки, {interval, select,\n month {а також керувати підпискою або скасувати її}\n year {а також керувати підпискою або скасувати її}\n other {а також керувати майбутніми пожертвами}\n} у будь-який час за цим посиланням:\n\n{manageUrl}\n\nТвоя підтримка допомагає {product_name} працювати. Дякуємо!\n\n– Команда {product_name}"
|
||||
"body": "Привіт,\n\nДякуємо за твою пожертву для {product_name}! Твою {interval, select,\n month {регулярну пожертву}\n year {регулярну пожертву}\n other {одноразову пожертву}\n} успішно {interval, select,\n month {налаштовано}\n year {налаштовано}\n other {оброблено}\n}.\n\nДеталі пожертви:\nСума: {amount} {interval, select,\n month {на місяць}\n year {на рік}\n other {}\n}\n\nStripe незабаром надішле тобі окрему квитанцію з рахунком у форматі PDF. Вона містить усі платіжні дані, і її можна використати для податкових цілей.\n\nТи можеш переглядати історію своїх пожертв, завантажувати рахунки, {interval, select,\n month {а також керувати підпискою або скасувати її}\n year {а також керувати підпискою або скасувати її}\n other {а також керувати майбутніми пожертвами}\n} у будь-який час за цим посиланням:\n\n{manageUrl}\n\nТвоя підтримка допомагає {product_name} працювати. Дякуємо!\n\n– Команда {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Керуй своїми пожертвами для {product_name}",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Cảm ơn bạn đã ủng hộ {product_name}",
|
||||
"body": "Xin chào,\n\nCảm ơn bạn đã ủng hộ {product_name}! {interval, select,\n month {Khoản ủng hộ định kỳ}\n year {Khoản ủng hộ định kỳ}\n other {Khoản ủng hộ một lần}\n} của bạn đã được {interval, select,\n month {thiết lập}\n year {thiết lập}\n other {xử lý}\n} thành công.\n\nChi tiết ủng hộ:\nSố tiền: {amount} {currency} {interval, select,\n month {mỗi tháng}\n year {mỗi năm}\n other {}\n}\n\nStripe sẽ sớm gửi cho bạn một email biên lai riêng kèm hóa đơn PDF. Biên lai này gồm đầy đủ chi tiết thanh toán và có thể dùng cho mục đích thuế.\n\nBạn có thể xem lịch sử ủng hộ, tải xuống hóa đơn, {interval, select,\n month {và quản lý hoặc hủy gói đăng ký}\n year {và quản lý hoặc hủy gói đăng ký}\n other {và quản lý các khoản ủng hộ trong tương lai}\n} bất cứ lúc nào bằng cách sử dụng liên kết này:\n\n{manageUrl}\n\nSự hỗ trợ của bạn giúp {product_name} tiếp tục hoạt động. Cảm ơn bạn!\n\n– Đội ngũ {product_name}"
|
||||
"body": "Xin chào,\n\nCảm ơn bạn đã ủng hộ {product_name}! {interval, select,\n month {Khoản ủng hộ định kỳ}\n year {Khoản ủng hộ định kỳ}\n other {Khoản ủng hộ một lần}\n} của bạn đã được {interval, select,\n month {thiết lập}\n year {thiết lập}\n other {xử lý}\n} thành công.\n\nChi tiết ủng hộ:\nSố tiền: {amount} {interval, select,\n month {mỗi tháng}\n year {mỗi năm}\n other {}\n}\n\nStripe sẽ sớm gửi cho bạn một email biên lai riêng kèm hóa đơn PDF. Biên lai này gồm đầy đủ chi tiết thanh toán và có thể dùng cho mục đích thuế.\n\nBạn có thể xem lịch sử ủng hộ, tải xuống hóa đơn, {interval, select,\n month {và quản lý hoặc hủy gói đăng ký}\n year {và quản lý hoặc hủy gói đăng ký}\n other {và quản lý các khoản ủng hộ trong tương lai}\n} bất cứ lúc nào bằng cách sử dụng liên kết này:\n\n{manageUrl}\n\nSự hỗ trợ của bạn giúp {product_name} tiếp tục hoạt động. Cảm ơn bạn!\n\n– Đội ngũ {product_name}"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Quản lý các khoản ủng hộ {product_name} của bạn",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "感谢你的 {product_name} 捐赠",
|
||||
"body": "你好,\n\n感谢你对 {product_name} 的捐赠!你的{interval, select,\n month {定期捐赠}\n year {定期捐赠}\n other {一次性捐赠}\n}{interval, select,\n month {已成功设置}\n year {已成功设置}\n other {已成功处理}\n}。\n\n捐赠详情:\n金额:{amount} {currency} {interval, select,\n month {每月}\n year {每年}\n other {}\n}\n\nStripe 稍后会另外发送一封收据邮件,其中附有发票 PDF。邮件包含所有付款详情,可用于报税。\n\n你可以随时使用此链接查看你的捐赠历史、下载发票,以及{interval, select,\n month {管理或取消你的订阅}\n year {管理或取消你的订阅}\n other {管理你的未来捐赠}\n}:\n\n{manageUrl}\n\n你的支持有助于 {product_name} 的持续运行。谢谢!\n\n– {product_name} 团队"
|
||||
"body": "你好,\n\n感谢你对 {product_name} 的捐赠!你的{interval, select,\n month {定期捐赠}\n year {定期捐赠}\n other {一次性捐赠}\n}{interval, select,\n month {已成功设置}\n year {已成功设置}\n other {已成功处理}\n}。\n\n捐赠详情:\n金额:{amount} {interval, select,\n month {每月}\n year {每年}\n other {}\n}\n\nStripe 稍后会另外发送一封收据邮件,其中附有发票 PDF。邮件包含所有付款详情,可用于报税。\n\n你可以随时使用此链接查看你的捐赠历史、下载发票,以及{interval, select,\n month {管理或取消你的订阅}\n year {管理或取消你的订阅}\n other {管理你的未来捐赠}\n}:\n\n{manageUrl}\n\n你的支持有助于 {product_name} 的持续运行。谢谢!\n\n– {product_name} 团队"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "管理你的 {product_name} 捐赠",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "感謝您對 {product_name} 的捐款",
|
||||
"body": "哈囉,\n\n感謝您對 {product_name} 的捐款!您的{interval, select,\n month {定期捐款}\n year {定期捐款}\n other {一次性捐款}\n}已成功{interval, select,\n month {設定}\n year {設定}\n other {處理}\n}。\n\n捐款詳情:\n金額:{amount} {currency} {interval, select,\n month {每月}\n year {每年}\n other {}\n}\n\nStripe 稍後會另外寄給您一封收據,其中附有發票 PDF。這份收據包含所有付款詳情,可用於報稅。\n\n您可以隨時使用此連結查看您的捐款紀錄、下載發票,{interval, select,\n month {以及管理或取消您的訂閱}\n year {以及管理或取消您的訂閱}\n other {以及管理未來的捐款}\n}:\n\n{manageUrl}\n\n您的支持讓 {product_name} 得以持續運作。謝謝您!\n\n– {product_name} 團隊"
|
||||
"body": "哈囉,\n\n感謝您對 {product_name} 的捐款!您的{interval, select,\n month {定期捐款}\n year {定期捐款}\n other {一次性捐款}\n}已成功{interval, select,\n month {設定}\n year {設定}\n other {處理}\n}。\n\n捐款詳情:\n金額:{amount} {interval, select,\n month {每月}\n year {每年}\n other {}\n}\n\nStripe 稍後會另外寄給您一封收據,其中附有發票 PDF。這份收據包含所有付款詳情,可用於報稅。\n\n您可以隨時使用此連結查看您的捐款紀錄、下載發票,{interval, select,\n month {以及管理或取消您的訂閱}\n year {以及管理或取消您的訂閱}\n other {以及管理未來的捐款}\n}:\n\n{manageUrl}\n\n您的支持讓 {product_name} 得以持續運作。謝謝您!\n\n– {product_name} 團隊"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "管理您的 {product_name} 捐款",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Thank you for your {product_name} donation",
|
||||
"body": "Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {currency} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team"
|
||||
"body": "Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team"
|
||||
},
|
||||
"donation_magic_link": {
|
||||
"subject": "Manage your {product_name} donations",
|
||||
|
||||
@@ -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<Row>(query: string): Promise<Array<Row>> {
|
||||
const rows: Array<Row> = [];
|
||||
let pageState: string | null = null;
|
||||
do {
|
||||
const page: PagedQueryResult<Row> = await fetchPage<Row>(query, {}, {pageSize: PAGE_SIZE, pageState});
|
||||
rows.push(...page.rows);
|
||||
pageState = page.pageState;
|
||||
} while (pageState !== null);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function backfillDonors(apply: boolean): Promise<Set<string>> {
|
||||
const rows = await scanTable<DonorRow>(SCAN_DONORS_QUERY);
|
||||
const conflicted = new Set<string>();
|
||||
let moved = 0;
|
||||
for (const row of rows) {
|
||||
const email = normalizeEmail(row.email);
|
||||
if (email === row.email) continue;
|
||||
const existing = await fetchOne<DonorRow>(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<string>): Promise<void> {
|
||||
const rows = await scanTable<DonorByStripeCustomerIdRow>(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<string>): Promise<void> {
|
||||
const rows = await scanTable<DonorByStripeSubscriptionIdRow>(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<string>): Promise<void> {
|
||||
const rows = await scanTable<DonorMagicLinkTokenRow>(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<string>): Promise<void> {
|
||||
const rows = await scanTable<DonorMagicLinkTokenByEmailRow>(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<void> {
|
||||
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);
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -134,6 +134,7 @@ export interface APIConfig {
|
||||
internal: {
|
||||
gateway: string;
|
||||
gatewayRpcAuthToken: string;
|
||||
donationProxyKey: string;
|
||||
};
|
||||
hosts: {
|
||||
invite: string;
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface DonorRow {
|
||||
subscription_interval: Nullish<string>;
|
||||
subscription_current_period_end: Nullish<Date>;
|
||||
subscription_cancel_at: Nullish<Date>;
|
||||
subscription_status: Nullish<string>;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
version: number;
|
||||
@@ -53,6 +54,7 @@ export const DONOR_COLUMNS = [
|
||||
'subscription_interval',
|
||||
'subscription_current_period_end',
|
||||
'subscription_cancel_at',
|
||||
'subscription_status',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'version',
|
||||
|
||||
@@ -6,6 +6,11 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {DonationRateLimitConfigs} from '@app/api/rate_limit_configs/DonationRateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {DonationMagicLinkExpiredError} from '@fluxer/errors/src/domains/donation/DonationMagicLinkExpiredError';
|
||||
import {DonationMagicLinkInvalidError} from '@fluxer/errors/src/domains/donation/DonationMagicLinkInvalidError';
|
||||
import {DonationMagicLinkUsedError} from '@fluxer/errors/src/domains/donation/DonationMagicLinkUsedError';
|
||||
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
|
||||
import {StripePaymentNotAvailableError} from '@fluxer/errors/src/domains/payment/StripePaymentNotAvailableError';
|
||||
import {
|
||||
DonationCheckoutRequest,
|
||||
DonationCheckoutResponse,
|
||||
@@ -13,6 +18,26 @@ import {
|
||||
DonationRequestLinkRequest,
|
||||
} from '@fluxer/schema/src/domains/donation/DonationSchemas';
|
||||
|
||||
function donationManageAlertUrl(alert: string): string {
|
||||
return `${Config.endpoints.marketing}/donate/manage?alert=${alert}`;
|
||||
}
|
||||
|
||||
function getMagicLinkAlert(error: unknown): string | null {
|
||||
if (error instanceof DonationMagicLinkExpiredError) {
|
||||
return 'link_expired';
|
||||
}
|
||||
if (error instanceof DonationMagicLinkUsedError) {
|
||||
return 'link_used';
|
||||
}
|
||||
if (error instanceof DonationMagicLinkInvalidError) {
|
||||
return 'link_invalid';
|
||||
}
|
||||
if (error instanceof StripeError || error instanceof StripePaymentNotAvailableError) {
|
||||
return 'portal_error';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function DonationController(app: HonoApp) {
|
||||
app.post(
|
||||
'/donations/request-link',
|
||||
@@ -38,8 +63,9 @@ export function DonationController(app: HonoApp) {
|
||||
RateLimitMiddleware(DonationRateLimitConfigs.DONATION_MANAGE),
|
||||
OpenAPI({
|
||||
operationId: 'manage_donation',
|
||||
summary: 'Manage donation subscription',
|
||||
description: 'Validates the magic link token and redirects to Stripe billing portal.',
|
||||
summary: 'Open donation management link',
|
||||
description:
|
||||
'Checks the magic link token without consuming it and redirects to the donation management confirmation page.',
|
||||
responseSchema: null,
|
||||
statusCode: 302,
|
||||
security: [],
|
||||
@@ -48,12 +74,50 @@ export function DonationController(app: HonoApp) {
|
||||
Validator('query', DonationManageQuery),
|
||||
async (ctx) => {
|
||||
const {token} = ctx.req.valid('query');
|
||||
const {stripeCustomerId} = await ctx.get('donationService').validateMagicLinkToken(token);
|
||||
if (!stripeCustomerId) {
|
||||
return ctx.redirect(`${Config.endpoints.marketing}/donate`);
|
||||
try {
|
||||
const {stripeCustomerId} = await ctx.get('donationService').validateMagicLinkToken(token);
|
||||
if (!stripeCustomerId) {
|
||||
return ctx.redirect(donationManageAlertUrl('no_customer'), 302);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const alert = getMagicLinkAlert(error);
|
||||
if (!alert) {
|
||||
throw error;
|
||||
}
|
||||
return ctx.redirect(donationManageAlertUrl(alert), 302);
|
||||
}
|
||||
const encodedToken = encodeURIComponent(token);
|
||||
return ctx.redirect(`${Config.endpoints.marketing}/donate/manage/confirm?token=${encodedToken}`, 302);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/donations/manage',
|
||||
RateLimitMiddleware(DonationRateLimitConfigs.DONATION_MANAGE),
|
||||
OpenAPI({
|
||||
operationId: 'redeem_donation_magic_link',
|
||||
summary: 'Redeem donation management link',
|
||||
description: 'Consumes the magic link token and redirects to the Stripe billing portal.',
|
||||
responseSchema: null,
|
||||
statusCode: 302,
|
||||
security: [],
|
||||
tags: 'Donations',
|
||||
}),
|
||||
Validator('query', DonationManageQuery),
|
||||
async (ctx) => {
|
||||
const {token} = ctx.req.valid('query');
|
||||
try {
|
||||
const portalUrl = await ctx.get('donationService').redeemMagicLinkToken(token);
|
||||
if (!portalUrl) {
|
||||
return ctx.redirect(donationManageAlertUrl('no_customer'), 302);
|
||||
}
|
||||
return ctx.redirect(portalUrl, 302);
|
||||
} catch (error: unknown) {
|
||||
const alert = getMagicLinkAlert(error);
|
||||
if (!alert) {
|
||||
throw error;
|
||||
}
|
||||
return ctx.redirect(donationManageAlertUrl(alert), 302);
|
||||
}
|
||||
const portalUrl = await ctx.get('donationService').createDonorPortalSession(stripeCustomerId);
|
||||
return ctx.redirect(portalUrl);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {BatchBuilder, deleteOneOrMany, fetchMany, fetchOne} from '@app/api/database/CassandraQueryExecution';
|
||||
import {BatchBuilder, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
|
||||
import {Db, type PatchObject} from '@app/api/database/CassandraTypes';
|
||||
import {executeVersionedUpdate} from '@app/api/database/CassandraVersionedUpdate';
|
||||
import type {
|
||||
@@ -101,6 +101,7 @@ export class DonationRepository extends IDonationRepository {
|
||||
subscriptionInterval: string | null;
|
||||
subscriptionCurrentPeriodEnd: Date | null;
|
||||
subscriptionCancelAt?: Date | null;
|
||||
subscriptionStatus?: string | null;
|
||||
}): Promise<Donor> {
|
||||
const now = new Date();
|
||||
const donorRow: DonorRow = {
|
||||
@@ -115,6 +116,7 @@ export class DonationRepository extends IDonationRepository {
|
||||
subscription_interval: data.subscriptionInterval,
|
||||
subscription_current_period_end: data.subscriptionCurrentPeriodEnd,
|
||||
subscription_cancel_at: data.subscriptionCancelAt ?? null,
|
||||
subscription_status: data.subscriptionStatus ?? null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
version: 1,
|
||||
@@ -154,6 +156,7 @@ export class DonationRepository extends IDonationRepository {
|
||||
subscriptionInterval: string | null;
|
||||
subscriptionCurrentPeriodEnd: Date | null;
|
||||
subscriptionCancelAt?: Date | null;
|
||||
subscriptionStatus?: string | null;
|
||||
},
|
||||
): Promise<Donor | null> {
|
||||
await executeVersionedUpdate(
|
||||
@@ -167,6 +170,7 @@ export class DonationRepository extends IDonationRepository {
|
||||
subscription_interval: Db.set(data.subscriptionInterval),
|
||||
subscription_current_period_end: Db.set(data.subscriptionCurrentPeriodEnd),
|
||||
subscription_cancel_at: Db.set(data.subscriptionCancelAt ?? null),
|
||||
subscription_status: Db.set(data.subscriptionStatus ?? null),
|
||||
updated_at: Db.set(new Date()),
|
||||
};
|
||||
if (data.businessName !== undefined) {
|
||||
@@ -206,6 +210,84 @@ export class DonationRepository extends IDonationRepository {
|
||||
return this.findDonorByEmail(email);
|
||||
}
|
||||
|
||||
async updateDonorCustomerDetails(
|
||||
email: string,
|
||||
data: {
|
||||
stripeCustomerId: string | null;
|
||||
businessName?: string | null;
|
||||
taxId?: string | null;
|
||||
taxIdType?: string | null;
|
||||
},
|
||||
): Promise<Donor | null> {
|
||||
await executeVersionedUpdate(
|
||||
() => fetchOne<DonorRow>(FETCH_DONOR_BY_EMAIL_QUERY, {email}),
|
||||
() => {
|
||||
const patch: PatchObject = {
|
||||
stripe_customer_id: Db.set(data.stripeCustomerId),
|
||||
updated_at: Db.set(new Date()),
|
||||
};
|
||||
if (data.businessName !== undefined) {
|
||||
patch.business_name = Db.set(data.businessName);
|
||||
}
|
||||
if (data.taxId !== undefined) {
|
||||
patch.tax_id = Db.set(data.taxId);
|
||||
}
|
||||
if (data.taxIdType !== undefined) {
|
||||
patch.tax_id_type = Db.set(data.taxIdType);
|
||||
}
|
||||
return {
|
||||
pk: {email},
|
||||
patch,
|
||||
};
|
||||
},
|
||||
Donors,
|
||||
);
|
||||
if (data.stripeCustomerId) {
|
||||
await upsertOne(
|
||||
DonorsByStripeCustomerId.upsertAll({
|
||||
stripe_customer_id: data.stripeCustomerId,
|
||||
email,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return this.findDonorByEmail(email);
|
||||
}
|
||||
|
||||
async linkDonorStripeCustomer(email: string, stripeCustomerId: string): Promise<void> {
|
||||
await upsertOne(
|
||||
DonorsByStripeCustomerId.upsertAll({
|
||||
stripe_customer_id: stripeCustomerId,
|
||||
email,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async clearDonorStripeCustomer(stripeCustomerId: string): Promise<void> {
|
||||
const donor = await this.findDonorByStripeCustomerId(stripeCustomerId);
|
||||
if (!donor) {
|
||||
return;
|
||||
}
|
||||
if (donor.stripeCustomerId === stripeCustomerId) {
|
||||
await executeVersionedUpdate(
|
||||
() => fetchOne<DonorRow>(FETCH_DONOR_BY_EMAIL_QUERY, {email: donor.email}),
|
||||
() => ({
|
||||
pk: {email: donor.email},
|
||||
patch: {
|
||||
stripe_customer_id: Db.clear(),
|
||||
updated_at: Db.set(new Date()),
|
||||
},
|
||||
}),
|
||||
Donors,
|
||||
);
|
||||
}
|
||||
await deleteOneOrMany(
|
||||
DonorsByStripeCustomerId.deleteByPk({
|
||||
stripe_customer_id: stripeCustomerId,
|
||||
email: donor.email,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async cancelDonorSubscription(email: string): Promise<void> {
|
||||
const current = await this.findDonorByEmail(email);
|
||||
if (!current) {
|
||||
@@ -223,6 +305,7 @@ export class DonationRepository extends IDonationRepository {
|
||||
subscription_interval: Db.clear(),
|
||||
subscription_current_period_end: Db.clear(),
|
||||
subscription_cancel_at: Db.clear(),
|
||||
subscription_status: Db.clear(),
|
||||
updated_at: Db.set(new Date()),
|
||||
},
|
||||
}),
|
||||
@@ -238,6 +321,15 @@ export class DonationRepository extends IDonationRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDonorSubscriptionMapping(stripeSubscriptionId: string, email: string): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
DonorsByStripeSubscriptionId.deleteByPk({
|
||||
stripe_subscription_id: stripeSubscriptionId,
|
||||
email,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async createMagicLinkToken(token: DonorMagicLinkToken): Promise<void> {
|
||||
const row = token.toRow();
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
@@ -22,6 +22,19 @@ export class DonationService implements IDonationService {
|
||||
return this.magicLinkService.validateToken(token);
|
||||
}
|
||||
|
||||
async redeemMagicLinkToken(token: string): Promise<string | null> {
|
||||
const {stripeCustomerId} = await this.magicLinkService.validateToken(token);
|
||||
if (!stripeCustomerId) {
|
||||
return null;
|
||||
}
|
||||
const portalUrl = await this.checkoutService.createPortalSession(stripeCustomerId);
|
||||
if (!portalUrl) {
|
||||
return null;
|
||||
}
|
||||
await this.magicLinkService.consumeToken(token);
|
||||
return portalUrl;
|
||||
}
|
||||
|
||||
async createDonationCheckout(params: {
|
||||
email: string;
|
||||
amountCents: number;
|
||||
@@ -32,8 +45,4 @@ export class DonationService implements IDonationService {
|
||||
}): Promise<string> {
|
||||
return this.checkoutService.createCheckout(params);
|
||||
}
|
||||
|
||||
async createDonorPortalSession(stripeCustomerId: string): Promise<string> {
|
||||
return this.checkoutService.createPortalSession(stripeCustomerId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export abstract class IDonationRepository {
|
||||
subscriptionInterval: string | null;
|
||||
subscriptionCurrentPeriodEnd: Date | null;
|
||||
subscriptionCancelAt?: Date | null;
|
||||
subscriptionStatus?: string | null;
|
||||
}): Promise<Donor>;
|
||||
|
||||
abstract updateDonorSubscription(
|
||||
@@ -39,11 +40,28 @@ export abstract class IDonationRepository {
|
||||
subscriptionInterval: string | null;
|
||||
subscriptionCurrentPeriodEnd: Date | null;
|
||||
subscriptionCancelAt?: Date | null;
|
||||
subscriptionStatus?: string | null;
|
||||
},
|
||||
): Promise<Donor | null>;
|
||||
|
||||
abstract updateDonorCustomerDetails(
|
||||
email: string,
|
||||
data: {
|
||||
stripeCustomerId: string | null;
|
||||
businessName?: string | null;
|
||||
taxId?: string | null;
|
||||
taxIdType?: string | null;
|
||||
},
|
||||
): Promise<Donor | null>;
|
||||
|
||||
abstract linkDonorStripeCustomer(email: string, stripeCustomerId: string): Promise<void>;
|
||||
|
||||
abstract clearDonorStripeCustomer(stripeCustomerId: string): Promise<void>;
|
||||
|
||||
abstract cancelDonorSubscription(email: string): Promise<void>;
|
||||
|
||||
abstract deleteDonorSubscriptionMapping(stripeSubscriptionId: string, email: string): Promise<void>;
|
||||
|
||||
abstract createMagicLinkToken(token: DonorMagicLinkToken): Promise<void>;
|
||||
|
||||
abstract findMagicLinkToken(token: string): Promise<DonorMagicLinkToken | null>;
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface IDonationService {
|
||||
email: string;
|
||||
stripeCustomerId: string | null;
|
||||
}>;
|
||||
redeemMagicLinkToken(token: string): Promise<string | null>;
|
||||
createDonationCheckout(params: {
|
||||
email: string;
|
||||
amountCents: number;
|
||||
@@ -16,5 +17,4 @@ export interface IDonationService {
|
||||
isBusiness?: boolean;
|
||||
locale?: string | null;
|
||||
}): Promise<string>;
|
||||
createDonorPortalSession(stripeCustomerId: string): Promise<string>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import {nextVersion} from '@app/api/database/CassandraTypes';
|
||||
import type {DonorRow} from '@app/api/database/types/DonationTypes';
|
||||
|
||||
const ACTIVE_SUBSCRIPTION_STATUSES: ReadonlySet<string> = new Set(['active', 'trialing']);
|
||||
|
||||
export class Donor {
|
||||
readonly email: string;
|
||||
readonly stripeCustomerId: string | null;
|
||||
@@ -15,6 +17,7 @@ export class Donor {
|
||||
readonly subscriptionInterval: string | null;
|
||||
readonly subscriptionCurrentPeriodEnd: Date | null;
|
||||
readonly subscriptionCancelAt: Date | null;
|
||||
readonly subscriptionStatus: string | null;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
readonly version: number;
|
||||
@@ -31,6 +34,7 @@ export class Donor {
|
||||
this.subscriptionInterval = row.subscription_interval ?? null;
|
||||
this.subscriptionCurrentPeriodEnd = row.subscription_current_period_end ?? null;
|
||||
this.subscriptionCancelAt = row.subscription_cancel_at ?? null;
|
||||
this.subscriptionStatus = row.subscription_status ?? null;
|
||||
this.createdAt = row.created_at;
|
||||
this.updatedAt = row.updated_at;
|
||||
this.version = row.version;
|
||||
@@ -49,6 +53,7 @@ export class Donor {
|
||||
subscription_interval: this.subscriptionInterval,
|
||||
subscription_current_period_end: this.subscriptionCurrentPeriodEnd,
|
||||
subscription_cancel_at: this.subscriptionCancelAt,
|
||||
subscription_status: this.subscriptionStatus,
|
||||
created_at: this.createdAt,
|
||||
updated_at: this.updatedAt,
|
||||
version: this.version,
|
||||
@@ -62,6 +67,9 @@ export class Donor {
|
||||
if (this.subscriptionCancelAt !== null) {
|
||||
return false;
|
||||
}
|
||||
if (this.subscriptionStatus !== null && !ACTIVE_SUBSCRIPTION_STATUSES.has(this.subscriptionStatus)) {
|
||||
return false;
|
||||
}
|
||||
return this.subscriptionCurrentPeriodEnd > new Date();
|
||||
}
|
||||
|
||||
@@ -80,6 +88,7 @@ export class Donor {
|
||||
subscriptionInterval: string | null;
|
||||
subscriptionCurrentPeriodEnd: Date | null;
|
||||
subscriptionCancelAt?: Date | null;
|
||||
subscriptionStatus?: string | null;
|
||||
}): DonorRow {
|
||||
return {
|
||||
email: this.email,
|
||||
@@ -93,6 +102,7 @@ export class Donor {
|
||||
subscription_interval: data.subscriptionInterval,
|
||||
subscription_current_period_end: data.subscriptionCurrentPeriodEnd,
|
||||
subscription_cancel_at: data.subscriptionCancelAt ?? this.subscriptionCancelAt,
|
||||
subscription_status: data.subscriptionStatus ?? this.subscriptionStatus,
|
||||
created_at: this.createdAt,
|
||||
updated_at: new Date(),
|
||||
version: nextVersion(this.version),
|
||||
|
||||
@@ -18,9 +18,62 @@ import type Stripe from 'stripe';
|
||||
type CheckoutSessionCreateParams = Stripe.Checkout.SessionCreateParams;
|
||||
type CheckoutSessionMode = CheckoutSessionCreateParams['mode'];
|
||||
type CheckoutSessionLineItem = NonNullable<CheckoutSessionCreateParams['line_items']>[number];
|
||||
type CheckoutSessionLocale = NonNullable<CheckoutSessionCreateParams['locale']>;
|
||||
|
||||
const PRODUCT_NAME = 'Fluxer';
|
||||
|
||||
const STRIPE_CHECKOUT_LOCALES: Record<string, CheckoutSessionLocale> = {
|
||||
bg: 'bg',
|
||||
cs: 'cs',
|
||||
da: 'da',
|
||||
de: 'de',
|
||||
el: 'el',
|
||||
'en-GB': 'en-GB',
|
||||
'en-US': 'en',
|
||||
'es-419': 'es-419',
|
||||
'es-ES': 'es',
|
||||
fi: 'fi',
|
||||
fr: 'fr',
|
||||
hr: 'hr',
|
||||
hu: 'hu',
|
||||
id: 'id',
|
||||
it: 'it',
|
||||
ja: 'ja',
|
||||
ko: 'ko',
|
||||
lt: 'lt',
|
||||
nl: 'nl',
|
||||
no: 'nb',
|
||||
pl: 'pl',
|
||||
'pt-BR': 'pt-BR',
|
||||
ro: 'ro',
|
||||
ru: 'ru',
|
||||
'sv-SE': 'sv',
|
||||
th: 'th',
|
||||
tr: 'tr',
|
||||
vi: 'vi',
|
||||
'zh-CN': 'zh',
|
||||
'zh-TW': 'zh-TW',
|
||||
};
|
||||
|
||||
function getStripeCheckoutLocale(locale: string | null): CheckoutSessionLocale {
|
||||
if (!locale) {
|
||||
return 'auto';
|
||||
}
|
||||
const checkoutLocale = STRIPE_CHECKOUT_LOCALES[locale];
|
||||
return checkoutLocale ?? 'auto';
|
||||
}
|
||||
|
||||
function isMissingCustomerError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
error.code === 'resource_missing' &&
|
||||
'param' in error &&
|
||||
error.param === 'customer'
|
||||
);
|
||||
}
|
||||
|
||||
function getDonationProductData(interval: 'month' | 'year' | null, locale: string | null) {
|
||||
if (interval === 'month') {
|
||||
return {
|
||||
@@ -68,8 +121,7 @@ export class DonationCheckoutService {
|
||||
const isRecurring = params.interval !== null;
|
||||
const existingDonor = await this.donationRepository.findDonorByEmail(params.email);
|
||||
if (isRecurring && existingDonor?.hasActiveSubscription()) {
|
||||
const encodedEmail = encodeURIComponent(params.email);
|
||||
return `${Config.endpoints.marketing}/donate/manage?email=${encodedEmail}&alert=active_subscription`;
|
||||
return `${Config.endpoints.marketing}/donate/manage?email=${encodeURIComponent(params.email)}&alert=active_subscription`;
|
||||
}
|
||||
const locale = params.locale ?? null;
|
||||
const donationProductData = getDonationProductData(params.interval, locale);
|
||||
@@ -96,18 +148,24 @@ export class DonationCheckoutService {
|
||||
quantity: 1,
|
||||
};
|
||||
const isBusiness = params.isBusiness === true;
|
||||
const donationMetadata = {
|
||||
is_donation: 'true',
|
||||
donation_email: params.email,
|
||||
};
|
||||
const reusableCustomerId = isRecurring ? (existingDonor?.stripeCustomerId ?? null) : null;
|
||||
const sessionParams: CheckoutSessionCreateParams = {
|
||||
line_items: [lineItem],
|
||||
mode,
|
||||
locale: getStripeCheckoutLocale(locale),
|
||||
...(reusableCustomerId ? {customer: reusableCustomerId} : {customer_email: params.email}),
|
||||
metadata: {
|
||||
is_donation: 'true',
|
||||
donation_email: params.email,
|
||||
...donationMetadata,
|
||||
donation_type: isRecurring ? 'recurring' : 'one_time',
|
||||
is_business: isBusiness ? 'true' : 'false',
|
||||
...(locale ? {donation_locale: locale} : {}),
|
||||
},
|
||||
success_url: `${Config.endpoints.marketing}/donate/success`,
|
||||
cancel_url: `${Config.endpoints.marketing}/donate`,
|
||||
cancel_url: `${Config.endpoints.marketing}/donate?type=${isBusiness ? 'business' : 'individual'}¤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<string> {
|
||||
async createPortalSession(stripeCustomerId: string): Promise<string | null> {
|
||||
if (!this.stripe) {
|
||||
throw new StripePaymentNotAvailableError();
|
||||
}
|
||||
@@ -177,9 +244,17 @@ export class DonationCheckoutService {
|
||||
Logger.debug({stripeCustomerId}, 'Donation portal session created');
|
||||
return session.url;
|
||||
} catch (error: unknown) {
|
||||
if (isMissingCustomerError(error)) {
|
||||
await this.clearStaleCustomerId(stripeCustomerId);
|
||||
return null;
|
||||
}
|
||||
Logger.error({error, stripeCustomerId}, 'Failed to create donor portal session');
|
||||
const message = error instanceof Error ? error.message : 'Failed to create portal session';
|
||||
throw new StripeError(message);
|
||||
throw new StripeError('Failed to create portal session');
|
||||
}
|
||||
}
|
||||
|
||||
private async clearStaleCustomerId(stripeCustomerId: string): Promise<void> {
|
||||
await this.donationRepository.clearDonorStripeCustomer(stripeCustomerId);
|
||||
Logger.warn({stripeCustomerId}, 'Cleared deleted Stripe customer from donor');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@ export class DonationMagicLinkService {
|
||||
if (tokenModel.isUsed()) {
|
||||
throw new DonationMagicLinkUsedError();
|
||||
}
|
||||
await this.donationRepository.markMagicLinkTokenUsed(token, new Date());
|
||||
const donor = await this.donationRepository.findDonorByEmail(tokenModel.donorEmail);
|
||||
Logger.debug({email: tokenModel.donorEmail}, 'Donation magic link validated');
|
||||
return {
|
||||
@@ -68,4 +67,8 @@ export class DonationMagicLinkService {
|
||||
stripeCustomerId: donor?.stripeCustomerId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async consumeToken(token: string): Promise<void> {
|
||||
await this.donationRepository.markMagicLinkTokenUsed(token, new Date());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,9 @@ describe('POST /donations/checkout', () => {
|
||||
DONATION_CURRENCY_VALUES.INR,
|
||||
DONATION_CURRENCY_VALUES.PLN,
|
||||
DONATION_CURRENCY_VALUES.TRY,
|
||||
DONATION_CURRENCY_VALUES.SEK,
|
||||
DONATION_CURRENCY_VALUES.DKK,
|
||||
DONATION_CURRENCY_VALUES.NOK,
|
||||
])('accepts %s currency', async (currency) => {
|
||||
const amount_cents = getDonationAmountConstraints(currency).minimumAmountMinor;
|
||||
const response = await createDonationCheckoutBuilder(harness)
|
||||
|
||||
@@ -13,10 +13,15 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa
|
||||
import {createPwnedPasswordsRangeHandler} from '@app/api/test/msw/handlers/PwnedPasswordsHandlers';
|
||||
import {createStripeApiHandlers, type StripeApiHandlers} from '@app/api/test/msw/handlers/StripeApiHandlers';
|
||||
import {server} from '@app/api/test/msw/server';
|
||||
import {createBuilderWithoutAuth, type TestRequestBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
describe('GET /donations/manage', () => {
|
||||
type DonationManageAlert = 'link_expired' | 'link_used' | 'link_invalid' | 'no_customer' | 'portal_error';
|
||||
|
||||
const STRIPE_PORTAL_URL_PATTERN = /^https:\/\/billing\.stripe\.com\/p\/session\//;
|
||||
|
||||
describe('GET and POST /donations/manage', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let stripeHandlers: StripeApiHandlers;
|
||||
let donationRepository: DonationRepository;
|
||||
@@ -33,6 +38,32 @@ describe('GET /donations/manage', () => {
|
||||
stripeHandlers.reset();
|
||||
server.use(...stripeHandlers.handlers);
|
||||
});
|
||||
function createDonationRedeemBuilder(token: string): TestRequestBuilder<void> {
|
||||
return createBuilderWithoutAuth<void>(harness).post(`/donations/manage?token=${encodeURIComponent(token)}`);
|
||||
}
|
||||
async function openManageLink(token: string): Promise<string | null> {
|
||||
const {response} = await createDonationManageBuilder(harness, token).expect(302).executeWithResponse();
|
||||
return response.headers.get('location');
|
||||
}
|
||||
async function redeemManageLink(token: string): Promise<string | null> {
|
||||
const {response} = await createDonationRedeemBuilder(token).expect(302).executeWithResponse();
|
||||
return response.headers.get('location');
|
||||
}
|
||||
function confirmUrl(token: string): string {
|
||||
return `${Config.endpoints.marketing}/donate/manage/confirm?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
function alertUrl(alert: DonationManageAlert): string {
|
||||
return `${Config.endpoints.marketing}/donate/manage?alert=${alert}`;
|
||||
}
|
||||
async function isTokenUsed(token: string): Promise<boolean | undefined> {
|
||||
const tokenModel = await donationRepository.findMagicLinkToken(token);
|
||||
return tokenModel?.isUsed();
|
||||
}
|
||||
function useFailingPortalHandlers(): void {
|
||||
server.resetHandlers();
|
||||
const failingHandlers = createStripeApiHandlers({portalShouldFail: true});
|
||||
server.use(createPwnedPasswordsRangeHandler(), ...failingHandlers.handlers);
|
||||
}
|
||||
async function createDonorWithCustomerId(email: string, customerId: string): Promise<void> {
|
||||
await donationRepository.createDonor({
|
||||
email,
|
||||
@@ -47,29 +78,21 @@ describe('GET /donations/manage', () => {
|
||||
subscriptionCurrentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
});
|
||||
}
|
||||
async function createValidMagicLinkToken(email: string, token: string): Promise<void> {
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
|
||||
const tokenModel = new DonorMagicLinkToken({
|
||||
token_: token,
|
||||
donor_email: email,
|
||||
expires_at: expiresAt,
|
||||
used_at: null,
|
||||
async function createDonorWithoutSubscription(email: string, customerId: string | null): Promise<void> {
|
||||
await donationRepository.createDonor({
|
||||
email,
|
||||
stripeCustomerId: customerId,
|
||||
businessName: null,
|
||||
taxId: null,
|
||||
taxIdType: null,
|
||||
stripeSubscriptionId: null,
|
||||
subscriptionAmountCents: null,
|
||||
subscriptionCurrency: null,
|
||||
subscriptionInterval: null,
|
||||
subscriptionCurrentPeriodEnd: null,
|
||||
});
|
||||
await donationRepository.createMagicLinkToken(tokenModel);
|
||||
}
|
||||
async function createExpiredMagicLinkToken(email: string, token: string): Promise<void> {
|
||||
const expiresAt = new Date(Date.now() - 1000);
|
||||
const tokenModel = new DonorMagicLinkToken({
|
||||
token_: token,
|
||||
donor_email: email,
|
||||
expires_at: expiresAt,
|
||||
used_at: null,
|
||||
});
|
||||
await donationRepository.createMagicLinkToken(tokenModel);
|
||||
}
|
||||
async function createUsedMagicLinkToken(email: string, token: string): Promise<void> {
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
|
||||
const usedAt = new Date(Date.now() - 5000);
|
||||
async function createMagicLinkToken(email: string, token: string, expiresAt: Date, usedAt: Date | null) {
|
||||
const tokenModel = new DonorMagicLinkToken({
|
||||
token_: token,
|
||||
donor_email: email,
|
||||
@@ -78,242 +101,279 @@ describe('GET /donations/manage', () => {
|
||||
});
|
||||
await donationRepository.createMagicLinkToken(tokenModel);
|
||||
}
|
||||
describe('valid token with customer ID', () => {
|
||||
async function createValidMagicLinkToken(email: string, token: string): Promise<void> {
|
||||
await createMagicLinkToken(email, token, new Date(Date.now() + 15 * 60 * 1000), null);
|
||||
}
|
||||
async function createExpiredMagicLinkToken(email: string, token: string): Promise<void> {
|
||||
await createMagicLinkToken(email, token, new Date(Date.now() - 1000), null);
|
||||
}
|
||||
async function createUsedMagicLinkToken(email: string, token: string): Promise<void> {
|
||||
await createMagicLinkToken(email, token, new Date(Date.now() + 15 * 60 * 1000), new Date(Date.now() - 5000));
|
||||
}
|
||||
describe('GET with a valid token and customer ID', () => {
|
||||
test('redirects to the confirmation page', async () => {
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_valid_123');
|
||||
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
|
||||
});
|
||||
test('does not open a Stripe billing portal session', async () => {
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_valid_123');
|
||||
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
await openManageLink(TEST_MAGIC_LINK_TOKEN);
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
describe('link scanner prefetch', () => {
|
||||
test('GET does not mark token as used', async () => {
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_prefetch');
|
||||
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
await openManageLink(TEST_MAGIC_LINK_TOKEN);
|
||||
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(false);
|
||||
});
|
||||
test('repeated GETs keep redirecting to the confirmation page', async () => {
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_prefetch');
|
||||
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
|
||||
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
|
||||
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
|
||||
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(false);
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
|
||||
});
|
||||
test('POST still redeems a token after repeated GETs', async () => {
|
||||
const customerId = 'cus_test_prefetch';
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
|
||||
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
await openManageLink(TEST_MAGIC_LINK_TOKEN);
|
||||
await openManageLink(TEST_MAGIC_LINK_TOKEN);
|
||||
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toMatch(STRIPE_PORTAL_URL_PATTERN);
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
|
||||
expect(stripeHandlers.spies.createdPortalSessions[0]?.customer).toBe(customerId);
|
||||
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(true);
|
||||
});
|
||||
});
|
||||
describe('POST with a valid token and customer ID', () => {
|
||||
test('redirects to Stripe billing portal', async () => {
|
||||
const customerId = 'cus_test_valid_123';
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
|
||||
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
const {response} = await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get('location')).toContain('https://billing.stripe.com');
|
||||
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toMatch(STRIPE_PORTAL_URL_PATTERN);
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
|
||||
expect(stripeHandlers.spies.createdPortalSessions[0]?.customer).toBe(customerId);
|
||||
});
|
||||
test('marks token as used after validation', async () => {
|
||||
const customerId = 'cus_test_valid_456';
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
|
||||
test('marks token as used after the portal session is created', async () => {
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_valid_456');
|
||||
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
|
||||
const token = await donationRepository.findMagicLinkToken(TEST_MAGIC_LINK_TOKEN);
|
||||
expect(token?.isUsed()).toBe(true);
|
||||
await redeemManageLink(TEST_MAGIC_LINK_TOKEN);
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
|
||||
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(true);
|
||||
});
|
||||
test('includes return_url in portal session', async () => {
|
||||
const customerId = 'cus_test_return_url';
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_return_url');
|
||||
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
|
||||
await redeemManageLink(TEST_MAGIC_LINK_TOKEN);
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
|
||||
const portalSession = stripeHandlers.spies.createdPortalSessions[0];
|
||||
expect(portalSession?.return_url).toBeDefined();
|
||||
expect(portalSession?.return_url).toBe(`${Config.endpoints.marketing}/donate`);
|
||||
});
|
||||
});
|
||||
describe('valid token without customer ID', () => {
|
||||
test('redirects to donation page when donor has no customer ID', async () => {
|
||||
await donationRepository.createDonor({
|
||||
email: TEST_DONOR_EMAIL,
|
||||
stripeCustomerId: null,
|
||||
businessName: null,
|
||||
taxId: null,
|
||||
taxIdType: null,
|
||||
stripeSubscriptionId: null,
|
||||
subscriptionAmountCents: null,
|
||||
subscriptionCurrency: null,
|
||||
subscriptionInterval: null,
|
||||
subscriptionCurrentPeriodEnd: null,
|
||||
});
|
||||
test('redirects to no_customer alert when donor has no customer ID', async () => {
|
||||
await createDonorWithoutSubscription(TEST_DONOR_EMAIL, null);
|
||||
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
const {response} = await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
|
||||
expect(response.status).toBe(302);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toContain(Config.endpoints.marketing);
|
||||
expect(location).toContain('/donate');
|
||||
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('no_customer'));
|
||||
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('no_customer'));
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
|
||||
});
|
||||
test('redirects to donation page when donor does not exist', async () => {
|
||||
test('redirects to no_customer alert when donor does not exist', async () => {
|
||||
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
const {response} = await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
|
||||
expect(response.status).toBe(302);
|
||||
const location = response.headers.get('location');
|
||||
expect(location).toContain(Config.endpoints.marketing);
|
||||
expect(location).toContain('/donate');
|
||||
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('no_customer'));
|
||||
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('no_customer'));
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
|
||||
});
|
||||
test('marks token as used even when redirecting to donate page', async () => {
|
||||
test('does not mark token as used when there is no customer', async () => {
|
||||
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
|
||||
const token = await donationRepository.findMagicLinkToken(TEST_MAGIC_LINK_TOKEN);
|
||||
expect(token?.isUsed()).toBe(true);
|
||||
await openManageLink(TEST_MAGIC_LINK_TOKEN);
|
||||
await redeemManageLink(TEST_MAGIC_LINK_TOKEN);
|
||||
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(false);
|
||||
});
|
||||
});
|
||||
describe('expired token handling', () => {
|
||||
test('rejects expired token', async () => {
|
||||
const customerId = 'cus_test_expired';
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_expired');
|
||||
await createExpiredMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
|
||||
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_EXPIRED)
|
||||
.execute();
|
||||
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
|
||||
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
|
||||
});
|
||||
test('does not mark expired token as used', async () => {
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test');
|
||||
await createExpiredMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
|
||||
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_EXPIRED)
|
||||
.execute();
|
||||
const token = await donationRepository.findMagicLinkToken(TEST_MAGIC_LINK_TOKEN);
|
||||
expect(token?.isUsed()).toBe(false);
|
||||
await openManageLink(TEST_MAGIC_LINK_TOKEN);
|
||||
await redeemManageLink(TEST_MAGIC_LINK_TOKEN);
|
||||
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(false);
|
||||
});
|
||||
test('rejects token expired by exactly 1 millisecond', async () => {
|
||||
const expiresAt = new Date(Date.now() - 1);
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test');
|
||||
const tokenModel = new DonorMagicLinkToken({
|
||||
token_: TEST_MAGIC_LINK_TOKEN,
|
||||
donor_email: TEST_DONOR_EMAIL,
|
||||
expires_at: expiresAt,
|
||||
used_at: null,
|
||||
});
|
||||
await donationRepository.createMagicLinkToken(tokenModel);
|
||||
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
|
||||
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_EXPIRED)
|
||||
.execute();
|
||||
await createMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN, new Date(Date.now() - 1), null);
|
||||
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
|
||||
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
describe('used token handling', () => {
|
||||
test('rejects already-used token', async () => {
|
||||
const customerId = 'cus_test_used';
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_used');
|
||||
await createUsedMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
|
||||
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_USED)
|
||||
.execute();
|
||||
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_used'));
|
||||
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_used'));
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
|
||||
});
|
||||
test('prevents reuse of token after successful redemption', async () => {
|
||||
const customerId = 'cus_test_reuse';
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_reuse');
|
||||
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
|
||||
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
|
||||
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_USED)
|
||||
.execute();
|
||||
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toMatch(STRIPE_PORTAL_URL_PATTERN);
|
||||
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_used'));
|
||||
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_used'));
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
describe('invalid token handling', () => {
|
||||
test('rejects non-existent token', async () => {
|
||||
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
|
||||
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_INVALID)
|
||||
.execute();
|
||||
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_invalid'));
|
||||
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_invalid'));
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
|
||||
});
|
||||
test('rejects token with invalid format', async () => {
|
||||
await createDonationManageBuilder(harness, TEST_INVALID_TOKEN).expect(400).execute();
|
||||
await createDonationManageBuilder(harness, TEST_INVALID_TOKEN)
|
||||
.expect(400, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
await createDonationRedeemBuilder(TEST_INVALID_TOKEN).expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
|
||||
});
|
||||
test('rejects empty token', async () => {
|
||||
await createDonationManageBuilder(harness, '').expect(400).execute();
|
||||
await createDonationManageBuilder(harness, '').expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
|
||||
await createDonationRedeemBuilder('').expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
|
||||
});
|
||||
test('rejects token that is too short', async () => {
|
||||
const shortToken = 'a'.repeat(63);
|
||||
await createDonationManageBuilder(harness, shortToken).expect(400).execute();
|
||||
await createDonationManageBuilder(harness, shortToken).expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
|
||||
await createDonationRedeemBuilder(shortToken).expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
|
||||
});
|
||||
test('rejects token that is too long', async () => {
|
||||
const longToken = 'a'.repeat(65);
|
||||
await createDonationManageBuilder(harness, longToken).expect(400).execute();
|
||||
await createDonationManageBuilder(harness, longToken).expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
|
||||
await createDonationRedeemBuilder(longToken).expect(400, APIErrorCodes.INVALID_FORM_BODY).execute();
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
|
||||
});
|
||||
test('rejects token with non-hex characters', async () => {
|
||||
const invalidToken = 'g'.repeat(64);
|
||||
await createDonationManageBuilder(harness, invalidToken).expect(400).execute();
|
||||
expect(await openManageLink(invalidToken)).toBe(alertUrl('link_invalid'));
|
||||
expect(await redeemManageLink(invalidToken)).toBe(alertUrl('link_invalid'));
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
|
||||
});
|
||||
test('rejects token with special characters', async () => {
|
||||
const specialToken = `${'a'.repeat(62)}@!`;
|
||||
await createDonationManageBuilder(harness, specialToken).expect(400).execute();
|
||||
expect(await openManageLink(specialToken)).toBe(alertUrl('link_invalid'));
|
||||
expect(await redeemManageLink(specialToken)).toBe(alertUrl('link_invalid'));
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
|
||||
});
|
||||
test('rejects token with whitespace', async () => {
|
||||
const whitespaceToken = `${'a'.repeat(32)} ${'a'.repeat(31)}`;
|
||||
await createDonationManageBuilder(harness, whitespaceToken).expect(400).execute();
|
||||
expect(await openManageLink(whitespaceToken)).toBe(alertUrl('link_invalid'));
|
||||
expect(await redeemManageLink(whitespaceToken)).toBe(alertUrl('link_invalid'));
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
describe('missing token parameter', () => {
|
||||
test('rejects request without token query parameter', async () => {
|
||||
const response = await harness.requestJson({
|
||||
const getResponse = await harness.requestJson({
|
||||
path: '/donations/manage',
|
||||
method: 'GET',
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(getResponse.status).toBe(400);
|
||||
const postResponse = await harness.requestJson({
|
||||
path: '/donations/manage',
|
||||
method: 'POST',
|
||||
});
|
||||
expect(postResponse.status).toBe(400);
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
describe('Stripe portal session creation', () => {
|
||||
test('handles Stripe API failure gracefully', async () => {
|
||||
const customerId = 'cus_test_stripe_fail';
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_stripe_fail');
|
||||
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
useFailingPortalHandlers();
|
||||
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('portal_error'));
|
||||
});
|
||||
test('does not mark token as used when the portal session fails', async () => {
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test_stripe_fail');
|
||||
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
useFailingPortalHandlers();
|
||||
await redeemManageLink(TEST_MAGIC_LINK_TOKEN);
|
||||
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(false);
|
||||
});
|
||||
test('redeems the same token once Stripe recovers', async () => {
|
||||
const customerId = 'cus_test_stripe_retry';
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
|
||||
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
useFailingPortalHandlers();
|
||||
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('portal_error'));
|
||||
server.resetHandlers();
|
||||
const failingHandlers = createStripeApiHandlers({portalShouldFail: true});
|
||||
server.use(createPwnedPasswordsRangeHandler(), ...failingHandlers.handlers);
|
||||
const {response} = await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
|
||||
expect(response.status).toBe(400);
|
||||
server.use(...stripeHandlers.handlers);
|
||||
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
|
||||
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toMatch(STRIPE_PORTAL_URL_PATTERN);
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
|
||||
expect(stripeHandlers.spies.createdPortalSessions[0]?.customer).toBe(customerId);
|
||||
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(true);
|
||||
});
|
||||
test('creates portal session with correct customer ID', async () => {
|
||||
const customerId = 'cus_test_specific_123';
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, customerId);
|
||||
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
|
||||
await redeemManageLink(TEST_MAGIC_LINK_TOKEN);
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
|
||||
const session = stripeHandlers.spies.createdPortalSessions[0];
|
||||
expect(session?.customer).toBe(customerId);
|
||||
});
|
||||
});
|
||||
describe('edge cases', () => {
|
||||
test('handles token for donor with cancelled subscription', async () => {
|
||||
await donationRepository.createDonor({
|
||||
email: TEST_DONOR_EMAIL,
|
||||
stripeCustomerId: 'cus_test_cancelled',
|
||||
businessName: null,
|
||||
taxId: null,
|
||||
taxIdType: null,
|
||||
stripeSubscriptionId: null,
|
||||
subscriptionAmountCents: null,
|
||||
subscriptionCurrency: null,
|
||||
subscriptionInterval: null,
|
||||
subscriptionCurrentPeriodEnd: null,
|
||||
});
|
||||
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
const {response} = await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN).executeRaw();
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get('location')).toContain('https://billing.stripe.com');
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
test('handles token expiring in exactly 0 milliseconds', async () => {
|
||||
const expiresAt = new Date(Date.now() - 1);
|
||||
test('handles token for donor with cancelled subscription', async () => {
|
||||
const customerId = 'cus_test_cancelled';
|
||||
await createDonorWithoutSubscription(TEST_DONOR_EMAIL, customerId);
|
||||
await createValidMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
|
||||
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toMatch(STRIPE_PORTAL_URL_PATTERN);
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(1);
|
||||
expect(stripeHandlers.spies.createdPortalSessions[0]?.customer).toBe(customerId);
|
||||
});
|
||||
test('handles token at its exact expiry instant', async () => {
|
||||
const expiresAt = new Date();
|
||||
vi.useFakeTimers({toFake: ['Date']});
|
||||
vi.setSystemTime(expiresAt);
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test');
|
||||
const tokenModel = new DonorMagicLinkToken({
|
||||
token_: TEST_MAGIC_LINK_TOKEN,
|
||||
donor_email: TEST_DONOR_EMAIL,
|
||||
expires_at: expiresAt,
|
||||
used_at: null,
|
||||
});
|
||||
await donationRepository.createMagicLinkToken(tokenModel);
|
||||
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
|
||||
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_EXPIRED)
|
||||
.execute();
|
||||
await createMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN, expiresAt, null);
|
||||
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(confirmUrl(TEST_MAGIC_LINK_TOKEN));
|
||||
vi.setSystemTime(expiresAt.getTime() + 1);
|
||||
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
|
||||
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
|
||||
expect(await isTokenUsed(TEST_MAGIC_LINK_TOKEN)).toBe(false);
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
|
||||
});
|
||||
test('handles case-sensitive token comparison', async () => {
|
||||
const lowerToken = 'a'.repeat(64);
|
||||
const upperToken = 'A'.repeat(64);
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test');
|
||||
await createValidMagicLinkToken(TEST_DONOR_EMAIL, lowerToken);
|
||||
await createDonationManageBuilder(harness, upperToken)
|
||||
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_INVALID)
|
||||
.execute();
|
||||
expect(await openManageLink(upperToken)).toBe(alertUrl('link_invalid'));
|
||||
expect(await redeemManageLink(upperToken)).toBe(alertUrl('link_invalid'));
|
||||
expect(await isTokenUsed(lowerToken)).toBe(false);
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(0);
|
||||
});
|
||||
test('handles multiple valid tokens for different donors', async () => {
|
||||
const token1 = 'a'.repeat(64);
|
||||
@@ -326,35 +386,37 @@ describe('GET /donations/manage', () => {
|
||||
await createDonorWithCustomerId(email2, customerId2);
|
||||
await createValidMagicLinkToken(email1, token1);
|
||||
await createValidMagicLinkToken(email2, token2);
|
||||
const {response: response1} = await createDonationManageBuilder(harness, token1).executeRaw();
|
||||
expect(response1.status).toBe(302);
|
||||
expect(await openManageLink(token1)).toBe(confirmUrl(token1));
|
||||
expect(await openManageLink(token2)).toBe(confirmUrl(token2));
|
||||
expect(await redeemManageLink(token1)).toMatch(STRIPE_PORTAL_URL_PATTERN);
|
||||
expect(stripeHandlers.spies.createdPortalSessions[0]?.customer).toBe(customerId1);
|
||||
const {response: response2} = await createDonationManageBuilder(harness, token2).executeRaw();
|
||||
expect(response2.status).toBe(302);
|
||||
expect(await isTokenUsed(token1)).toBe(true);
|
||||
expect(await isTokenUsed(token2)).toBe(false);
|
||||
expect(await redeemManageLink(token2)).toMatch(STRIPE_PORTAL_URL_PATTERN);
|
||||
expect(stripeHandlers.spies.createdPortalSessions[1]?.customer).toBe(customerId2);
|
||||
expect(stripeHandlers.spies.createdPortalSessions).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
describe('token validation order', () => {
|
||||
test('checks token existence before expiration', async () => {
|
||||
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
|
||||
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_INVALID)
|
||||
.execute();
|
||||
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_invalid'));
|
||||
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_invalid'));
|
||||
});
|
||||
test('checks expiration before usage status', async () => {
|
||||
const expiresAt = new Date(Date.now() - 1000);
|
||||
const usedAt = new Date(Date.now() - 5000);
|
||||
await createDonorWithCustomerId(TEST_DONOR_EMAIL, 'cus_test');
|
||||
const tokenModel = new DonorMagicLinkToken({
|
||||
token_: TEST_MAGIC_LINK_TOKEN,
|
||||
donor_email: TEST_DONOR_EMAIL,
|
||||
expires_at: expiresAt,
|
||||
used_at: usedAt,
|
||||
});
|
||||
await donationRepository.createMagicLinkToken(tokenModel);
|
||||
await createDonationManageBuilder(harness, TEST_MAGIC_LINK_TOKEN)
|
||||
.expect(400, APIErrorCodes.DONATION_MAGIC_LINK_EXPIRED)
|
||||
.execute();
|
||||
await createMagicLinkToken(
|
||||
TEST_DONOR_EMAIL,
|
||||
TEST_MAGIC_LINK_TOKEN,
|
||||
new Date(Date.now() - 1000),
|
||||
new Date(Date.now() - 5000),
|
||||
);
|
||||
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
|
||||
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_expired'));
|
||||
});
|
||||
test('checks usage status before customer lookup', async () => {
|
||||
await createUsedMagicLinkToken(TEST_DONOR_EMAIL, TEST_MAGIC_LINK_TOKEN);
|
||||
expect(await openManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_used'));
|
||||
expect(await redeemManageLink(TEST_MAGIC_LINK_TOKEN)).toBe(alertUrl('link_used'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,8 +112,12 @@ describe('POST /donations/request-link', () => {
|
||||
const longEmail = `${longLocalPart}@example.com`;
|
||||
await createDonationRequestLinkBuilder(harness).body({email: longEmail}).expect(400).execute();
|
||||
});
|
||||
test('rejects email with leading/trailing whitespace', async () => {
|
||||
await createDonationRequestLinkBuilder(harness).body({email: ' [email protected] '}).expect(400).execute();
|
||||
test('normalizes surrounding whitespace and letter case to the stored donor', async () => {
|
||||
await createDonor(TEST_DONOR_EMAIL);
|
||||
await createDonationRequestLinkBuilder(harness)
|
||||
.body({email: ` ${TEST_DONOR_EMAIL.toUpperCase()} `})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
describe('idempotency', () => {
|
||||
|
||||
@@ -52,6 +52,9 @@ export const DONATION_CURRENCY_VALUES = {
|
||||
INR: DONATION_CURRENCIES[3],
|
||||
PLN: DONATION_CURRENCIES[4],
|
||||
TRY: DONATION_CURRENCIES[5],
|
||||
SEK: DONATION_CURRENCIES[6],
|
||||
DKK: DONATION_CURRENCIES[7],
|
||||
NOK: DONATION_CURRENCIES[8],
|
||||
} as const;
|
||||
export const DONATION_INTERVALS = {
|
||||
MONTH: 'month',
|
||||
|
||||
@@ -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<HonoEnv>): boolean {
|
||||
if (!Config.dev.testModeEnabled) {
|
||||
@@ -49,7 +57,27 @@ function shouldShowHeadersOnSuccess(accountType: AccountType): boolean {
|
||||
return accountType === 'bot' || accountType === 'webhook';
|
||||
}
|
||||
|
||||
function getClientIdentifier(ctx: Context<HonoEnv>): string {
|
||||
function isTrustedInternalCaller(ctx: Context<HonoEnv>): 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<HonoEnv>): 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<HonoEnv>, 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<HonoEnv>): 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<HonoEnv>): Promise<string | null> {
|
||||
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<HonoEnv>): 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<HonoEnv>
|
||||
|
||||
export function RateLimitMiddleware(routeConfig: RouteRateLimitConfig): MiddlewareHandler<HonoEnv> {
|
||||
const routeBucketHash = getBucketHash(routeConfig.bucket);
|
||||
const emailBucketHash = routeConfig.emailBucket ? getBucketHash(routeConfig.emailBucket.bucket) : undefined;
|
||||
return createMiddleware<HonoEnv>(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);
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const confirmationSentKey = this.getDonationConfirmationSentKey(sessionId);
|
||||
if (await this.cacheService.get<boolean>(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<void> {
|
||||
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<DonationCustomerDetails> {
|
||||
private getDonationConfirmationSentKey(checkoutSessionId: string): string {
|
||||
return `stripe:checkout:donation:confirmation:sent:${checkoutSessionId}`;
|
||||
}
|
||||
|
||||
private async loadDonationCustomerDetails(customerId: string, isBusiness: boolean): Promise<DonationCustomerDetails> {
|
||||
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');
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<Donor | null> {
|
||||
const customerId = await this.resolveDisputeCustomerId(dispute);
|
||||
if (!customerId) {
|
||||
return null;
|
||||
}
|
||||
return await this.donationRepository.findDonorByStripeCustomerId(customerId);
|
||||
}
|
||||
|
||||
async handleRefund(charge: Stripe.Charge): Promise<void> {
|
||||
const paymentIntentId = extractId(charge.payment_intent);
|
||||
if (!paymentIntentId) {
|
||||
|
||||
@@ -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<Stripe.Subscription.Status> = 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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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(
|
||||
{
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user