From 413e044515457990dc51b6057fd1a5516a46abcf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 19 Oct 2026 16:47:13 +0000 Subject: [PATCH 1/2] docs(api): update API changelog from spec diff [skip ci] --- developer-portal/docs/changelog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/developer-portal/docs/changelog.md b/developer-portal/docs/changelog.md index 1a4f3535..7df9a15b 100644 --- a/developer-portal/docs/changelog.md +++ b/developer-portal/docs/changelog.md @@ -4,6 +4,10 @@ _No API changes detected._ +## [1.0.0] - 2026-10-19 + +_No API changes detected._ + All notable changes to the SubTrackr API and SDKs will be documented in this file. ## [1.2.0] - 2026-07-24 From fe238237b89e23474dc0d225b2bb6ee1bd298c0b Mon Sep 17 00:00:00 2001 From: bulusmalaga Date: Mon, 19 Oct 2026 10:13:55 -0700 Subject: [PATCH 2/2] feat(webhooks): optional signing, localized templates, retry service (#1751 #1752 #1753) Issue #1751: Make webhook signing optional so partners can subscribe to events without HMAC. signingEnabled flag on WebhookConfig; signature header omitted when disabled. Retries with backoff already existed. Issue #1752: LocalizedTemplateService renders notification templates per user locale (en/es/fr/ar) with fallback to default locale (en). Issue #1753: NotificationRetryService wraps channel transports with exponential backoff, dead-letter queue, and metrics for failed sends. --- .../__tests__/localizedTemplates.test.ts | 204 +++++++++++ .../notification/__tests__/webhook.test.ts | 78 ++++ .../notification/localizedTemplates.ts | 340 ++++++++++++++++++ .../notification/notificationRetryService.ts | 231 ++++++++++++ backend/services/notification/webhook.ts | 49 ++- src/store/webhookStore.ts | 1 + src/types/webhook.ts | 13 +- 7 files changed, 905 insertions(+), 11 deletions(-) create mode 100644 backend/services/notification/__tests__/localizedTemplates.test.ts create mode 100644 backend/services/notification/localizedTemplates.ts create mode 100644 backend/services/notification/notificationRetryService.ts diff --git a/backend/services/notification/__tests__/localizedTemplates.test.ts b/backend/services/notification/__tests__/localizedTemplates.test.ts new file mode 100644 index 00000000..b1709da2 --- /dev/null +++ b/backend/services/notification/__tests__/localizedTemplates.test.ts @@ -0,0 +1,204 @@ +/** + * Tests for LocalisedTemplateService (#1752) + * + * Covers: + * • Locale selection — requested locale is honoured when available. + * • Fallback locale — missing translations fall back to the default (`en`). + * • Variable substitution and missing-variable reporting. + * • Locale alias normalisation (en-US → en). + * • Unknown template throws. + */ + +import { describe, expect, it } from '@jest/globals'; +import { + DEFAULT_LOCALE, + LocalizedTemplateService, + SUPPORTED_LOCALES, + default as defaultExport, + localizedTemplateService, + resolveLocale, + substituteVariables, + DEFAULT_TEMPLATE_CATALOG, +} from '../localizedTemplates'; + +describe('resolveLocale', () => { + it('returns the locale when it is directly supported', () => { + expect(resolveLocale('en')).toBe('en'); + expect(resolveLocale('es')).toBe('es'); + expect(resolveLocale('fr')).toBe('fr'); + expect(resolveLocale('ar')).toBe('ar'); + }); + + it('normalises case and whitespace', () => { + expect(resolveLocale(' EN ')).toBe('en'); + expect(resolveLocale('FR')).toBe('fr'); + }); + + it('maps common locale tags to their base', () => { + expect(resolveLocale('en-US')).toBe('en'); + expect(resolveLocale('es-MX')).toBe('es'); + expect(resolveLocale('fr-CA')).toBe('fr'); + expect(resolveLocale('ar-SA')).toBe('ar'); + }); + + it('falls back to the default locale for unknown input', () => { + expect(resolveLocale('de')).toBe(DEFAULT_LOCALE); + expect(resolveLocale('')).toBe(DEFAULT_LOCALE); + expect(resolveLocale(undefined)).toBe(DEFAULT_LOCALE); + expect(resolveLocale(null)).toBe(DEFAULT_LOCALE); + }); +}); + +describe('substituteVariables', () => { + it('replaces {{variable}} placeholders with values', () => { + const result = substituteVariables('Hello {{name}}, you owe {{currency}} {{amount}}.', { + name: 'Dana', + currency: 'USD', + amount: '49.00', + }); + expect(result.text).toBe('Hello Dana, you owe USD 49.00.'); + expect(result.missing).toEqual([]); + }); + + it('records missing variables and renders an empty string', () => { + const result = substituteVariables('Hi {{name}}, plan: {{plan}}', { name: 'Dana' }); + expect(result.text).toBe('Hi Dana, plan: '); + expect(result.missing).toEqual(['plan']); + }); + + it('de-duplicates missing variables', () => { + const result = substituteVariables('{{a}} and {{a}}', {}); + expect(result.missing).toEqual(['a']); + }); + + it('treats an empty-string value as missing', () => { + const result = substituteVariables('v={{x}}', { x: '' }); + expect(result.text).toBe('v='); + expect(result.missing).toEqual(['x']); + }); +}); + +describe('LocalizedTemplateService', () => { + it('lists locales that have at least one template', () => { + const service = new LocalizedTemplateService(); + const locales = service.listLocales(); + for (const supported of SUPPORTED_LOCALES) { + expect(locales).toContain(supported); + } + }); + + it('lists the union of template ids across locales', () => { + const service = new LocalizedTemplateService(); + const ids = service.listTemplateIds(); + expect(ids).toContain('receipt_payment_succeeded'); + expect(ids).toContain('payment_failed'); + expect(ids).toContain('password_reset'); + }); + + // ── Locale selection ────────────────────────────────────────────────────────── + + it('renders in the requested locale when a translation exists', () => { + const service = new LocalizedTemplateService(); + const result = service.renderLocalized('payment_failed', 'es', { + subscription_name: 'Pro Plan', + currency: 'EUR', + amount: '49.00', + }); + expect(result.resolvedLocale).toBe('es'); + expect(result.fellBack).toBe(false); + expect(result.subject).toBe('Tu pago de Pro Plan ha fallado'); + expect(result.body).toContain('No pudimos procesar tu pago de EUR 49.00 por Pro Plan'); + expect(result.missingVariables).toEqual([]); + }); + + it('renders Arabic copy for the ar locale', () => { + const service = new LocalizedTemplateService(); + const result = service.renderLocalized('subscription_renewed', 'ar', { + subscription_name: 'Pro Plan', + currency: 'USD', + amount: '10.00', + next_billing_date: '2026-11-01', + }); + expect(result.resolvedLocale).toBe('ar'); + expect(result.subject).toBe('تم تجديد اشتراك Pro Plan'); + expect(result.body).toContain('2026-11-01'); + }); + + // ── Fallback locale ─────────────────────────────────────────────────────────── + + it('falls back to the default locale when the requested one has no translation', () => { + // German has no translations bundled — should fall back to English. + const service = new LocalizedTemplateService(); + const result = service.renderLocalized('payment_failed', 'de', { + subscription_name: 'Pro Plan', + currency: 'USD', + amount: '49.00', + }); + expect(result.resolvedLocale).toBe('en'); + expect(result.fellBack).toBe(true); + expect(result.subject).toBe('Your payment for Pro Plan failed'); + }); + + it('falls back when a specific template is missing from a supported locale', () => { + // Build a catalog where Spanish only has one template; everything else + // falls back to English. + const partialCatalog = { + en: DEFAULT_TEMPLATE_CATALOG.en, + es: { payment_failed: DEFAULT_TEMPLATE_CATALOG.es!.payment_failed }, + }; + const service = new LocalizedTemplateService(partialCatalog as any); + + const fallback = service.renderLocalized('receipt_payment_succeeded', 'es', { + subscription_name: 'Pro Plan', + currency: 'USD', + amount: '49.00', + paid_at: '2026-10-19', + }); + expect(fallback.resolvedLocale).toBe('en'); + expect(fallback.fellBack).toBe(true); + expect(fallback.subject).toBe('Receipt for your Pro Plan payment'); + + // The one Spanish template that does exist renders in Spanish. + const spanish = service.renderLocalized('payment_failed', 'es', { + subscription_name: 'Pro Plan', + currency: 'USD', + amount: '49.00', + }); + expect(spanish.resolvedLocale).toBe('es'); + expect(spanish.fellBack).toBe(false); + expect(spanish.subject).toBe('Tu pago de Pro Plan ha fallado'); + }); + + it('throws when neither the requested nor default locale has the template', () => { + const service = new LocalizedTemplateService({ en: {}, es: {} } as any); + expect(() => + service.renderLocalized('payment_failed', 'es', { subscription_name: 'X', currency: 'USD', amount: '1' }), + ).toThrow(/No template "payment_failed"/); + }); + + // ── Variable substitution through renderLocalized ──────────────────────────── + + it('reports missing variables without leaking placeholders', () => { + const service = new LocalizedTemplateService(); + const result = service.renderLocalized('receipt_payment_succeeded', 'en', { + subscription_name: 'Pro Plan', + // amount and currency deliberately omitted + }); + expect(result.subject).toBe('Receipt for your Pro Plan payment'); + expect(result.body).not.toContain('{{'); + expect(result.missingVariables).toContain('amount'); + expect(result.missingVariables).toContain('currency'); + expect(result.missingVariables).toContain('paid_at'); + }); + + it('uses the singleton service by default', () => { + expect(localizedTemplateService).toBeInstanceOf(LocalizedTemplateService); + const result = localizedTemplateService.renderLocalized('password_reset', 'fr', { + merchant_name: 'Acme', + reset_url: 'https://example.com/reset', + expires_in_minutes: '30', + }); + expect(result.resolvedLocale).toBe('fr'); + expect(result.subject).toBe('Réinitialisez votre mot de passe Acme'); + }); +}); diff --git a/backend/services/notification/__tests__/webhook.test.ts b/backend/services/notification/__tests__/webhook.test.ts index 447671df..71257b64 100644 --- a/backend/services/notification/__tests__/webhook.test.ts +++ b/backend/services/notification/__tests__/webhook.test.ts @@ -468,4 +468,82 @@ describe('WebhookDeliveryService', () => { expect(result.delivery.status).toBe('delivered'); expect(result.delivery.payload.merchantId).toBe('override_merchant'); }); + + // ── Optional Signing (Issue #1751) ─────────────────────────────────────────── + + it('registers an unsigned webhook when signingEnabled is false', () => { + const service = new WebhookDeliveryService({ fetchImpl: jest.fn() as unknown as typeof fetch }); + + const webhook = service.registerWebhook({ + merchantId: 'merchant_1', + url: 'https://partner.example.com/webhook', + events: ['subscription.created' as any], + signingEnabled: false, + }); + + expect(webhook.signingEnabled).toBe(false); + expect(webhook.secretKey).toBeUndefined(); + expect(webhook.secrets).toHaveLength(0); + }); + + it('delivers an unsigned event without the X-SubTrackr-Signature header', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ ok: true, status: 200 }); + const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch }); + + const webhook = service.registerWebhook({ + merchantId: 'merchant_1', + url: 'https://partner.example.com/webhook', + events: ['subscription.charged'], + signingEnabled: false, + }); + + const result = await service.deliverEvent(makeInput({ webhookId: webhook.id })); + + expect(result?.delivery.status).toBe('delivered'); + expect(result?.delivery.signature).toBeUndefined(); + + const [, init] = fetchImpl.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers['X-SubTrackr-Signature']).toBeUndefined(); + // Other metadata headers must still be present + expect(headers['X-SubTrackr-Event-Type']).toBe('subscription.charged'); + }); + + it('throws when registering with signingEnabled=true but no secretKey', () => { + const service = new WebhookDeliveryService({ fetchImpl: jest.fn() as unknown as typeof fetch }); + + expect(() => + service.registerWebhook({ + merchantId: 'merchant_1', + url: 'https://example.com/webhook', + events: ['subscription.charged'], + signingEnabled: true, + // secretKey deliberately omitted + }) + ).toThrow(/secretKey is required when signingEnabled is true/); + }); + + it('can upgrade an unsigned webhook to signed by updating with a secretKey', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ ok: true, status: 200 }); + const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch }); + + const webhook = service.registerWebhook({ + merchantId: 'merchant_1', + url: 'https://partner.example.com/webhook', + events: ['subscription.charged'], + signingEnabled: false, + }); + + const updated = service.updateWebhook(webhook.id, { + signingEnabled: true, + secretKey: 'new-secret', + }); + + expect(updated.signingEnabled).toBe(true); + expect(updated.secretKey).toBe('new-secret'); + + const result = await service.deliverEvent(makeInput({ webhookId: webhook.id })); + expect(result?.delivery.status).toBe('delivered'); + expect(result?.delivery.signature).toBeDefined(); + }); }); diff --git a/backend/services/notification/localizedTemplates.ts b/backend/services/notification/localizedTemplates.ts new file mode 100644 index 00000000..1969fb82 --- /dev/null +++ b/backend/services/notification/localizedTemplates.ts @@ -0,0 +1,340 @@ +/** + * Localised notification templates (#1752) + * + * Templates used to be English-only. This module adds locale-aware rendering + * for transactional notifications so partners and subscribers receive copy in + * their preferred language, with a deterministic fallback to the default + * locale (`en`) when a translation is missing. + * + * Design + * ────── + * • `LocaleCatalog` — a record of `locale → translationKey → LocalizedCopy`. + * • `resolveLocale(locale)` — normalises to a supported locale, falling back + * to `DEFAULT_LOCALE` when the requested one is unknown. + * • `renderLocalized(...)` — resolves the copy for a (templateId, locale), + * falling back per-key to the default locale so a missing Spanish + * translation still renders the English string instead of `{{amount}}`. + * • `substituteVariables` — the same `{{variable}}` placeholder syntax used + * by the rest of the notification pipeline. + * + * The service is transport-agnostic: it returns `{ subject, body }` strings + * that email, SMS, push, and in-app channels can consume. Rich-text HTML + * sanitisation still happens downstream via `templateService.ts`. + */ + +/** BCP-47-ish locale codes used by SubTrackr. Kept intentionally small. */ +export type SupportedLocale = 'en' | 'es' | 'fr' | 'ar'; + +/** Fallback locale used when the requested locale has no translation. */ +export const DEFAULT_LOCALE: SupportedLocale = 'en'; + +export const SUPPORTED_LOCALES: readonly SupportedLocale[] = ['en', 'es', 'fr', 'ar']; + +export interface LocalizedCopy { + /** Plain-text subject line (no HTML). */ + subject: string; + /** Body text with `{{variable}}` placeholders. */ + body: string; +} + +/** Translation keys for every localisable notification template. */ +export type LocalizedTemplateId = + | 'receipt_payment_succeeded' + | 'invoice_due' + | 'payment_failed' + | 'subscription_renewed' + | 'subscription_cancelled' + | 'trial_ending' + | 'password_reset' + | 'email_verification'; + +/** locale → templateId → copy. */ +export type LocaleCatalog = Record>>; + +export interface RenderedTemplate { + templateId: LocalizedTemplateId; + /** The locale that was actually used (may differ from the request when fallback kicked in). */ + resolvedLocale: SupportedLocale; + /** True when the requested locale had no entry and the default locale was used. */ + fellBack: boolean; + subject: string; + body: string; + /** Variables declared in the template that the caller did not supply. */ + missingVariables: string[]; +} + +// ─── Locale resolution ───────────────────────────────────────────────────────── + +const LOCALE_ALIASES: Record = { + 'en-US': 'en', + 'en-GB': 'en', + 'es-ES': 'es', + 'es-MX': 'es', + 'fr-FR': 'fr', + 'fr-CA': 'fr', + 'ar-SA': 'ar', + 'ar-EG': 'ar', +}; + +/** + * Normalises a caller-supplied locale tag to a supported locale. + * Returns `DEFAULT_LOCALE` for unknown / empty input so callers always get + * a usable locale back. + */ +export function resolveLocale(locale: string | undefined | null): SupportedLocale { + if (!locale || typeof locale !== 'string') return DEFAULT_LOCALE; + const normalised = locale.trim().toLowerCase(); + if (!normalised) return DEFAULT_LOCALE; + if ((SUPPORTED_LOCALES as readonly string[]).includes(normalised)) { + return normalised as SupportedLocale; + } + const alias = LOCALE_ALIASES[normalised] ?? LOCALE_ALIASES[normalised.split('-')[0]]; + return alias ?? DEFAULT_LOCALE; +} + +// ─── Variable substitution ────────────────────────────────────────────────────── + +const VARIABLE_PATTERN = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g; + +/** Replace `{{variable}}` placeholders with values from `variables`. */ +export function substituteVariables( + template: string, + variables: Record = {}, +): { text: string; missing: string[] } { + const missing: string[] = []; + const text = template.replace(VARIABLE_PATTERN, (_match, name: string) => { + const value = variables[name]; + if (value === undefined || value === null || value === '') { + // Record the missing variable but render an empty string — never leak + // the raw `{{placeholder}}` to a recipient. + if (!missing.includes(name)) missing.push(name); + return ''; + } + return String(value); + }); + return { text, missing }; +} + +// ─── Default catalog ─────────────────────────────────────────────────────────── + +export const DEFAULT_TEMPLATE_CATALOG: LocaleCatalog = { + en: { + receipt_payment_succeeded: { + subject: 'Receipt for your {{subscription_name}} payment', + body: 'We received your payment of {{currency}} {{amount}} for {{subscription_name}} on {{paid_at}}.', + }, + invoice_due: { + subject: 'Your invoice for {{subscription_name}} is due on {{due_date}}', + body: 'Invoice for {{subscription_name}} is due on {{due_date}}. Amount payable: {{currency}} {{amount}}.', + }, + payment_failed: { + subject: 'Your payment for {{subscription_name}} failed', + body: 'We could not process your payment of {{currency}} {{amount}} for {{subscription_name}}. Please update your payment method.', + }, + subscription_renewed: { + subject: 'Your {{subscription_name}} subscription renewed', + body: 'Your {{subscription_name}} subscription renewed for {{currency}} {{amount}}. Next renewal: {{next_billing_date}}.', + }, + subscription_cancelled: { + subject: 'Your {{subscription_name}} subscription was cancelled', + body: 'Your {{subscription_name}} subscription has been cancelled. You will retain access until {{access_until}}.', + }, + trial_ending: { + subject: 'Your {{subscription_name}} trial ends on {{trial_end_date}}', + body: 'Your free trial of {{subscription_name}} ends on {{trial_end_date}}. Add a payment method to keep your subscription active.', + }, + password_reset: { + subject: 'Reset your {{merchant_name}} password', + body: 'We received a request to reset your password. This link expires in {{expires_in_minutes}} minutes: {{reset_url}}', + }, + email_verification: { + subject: 'Verify your email address', + body: 'Confirm this address to finish setting up your account: {{verification_url}}', + }, + }, + es: { + receipt_payment_succeeded: { + subject: 'Recibo de tu pago de {{subscription_name}}', + body: 'Recibimos tu pago de {{currency}} {{amount}} por {{subscription_name}} el {{paid_at}}.', + }, + invoice_due: { + subject: 'Tu factura de {{subscription_name}} vence el {{due_date}}', + body: 'La factura de {{subscription_name}} vence el {{due_date}}. Importe a pagar: {{currency}} {{amount}}.', + }, + payment_failed: { + subject: 'Tu pago de {{subscription_name}} ha fallado', + body: 'No pudimos procesar tu pago de {{currency}} {{amount}} por {{subscription_name}}. Actualiza tu método de pago.', + }, + subscription_renewed: { + subject: 'Tu suscripción a {{subscription_name}} se renovó', + body: 'Tu suscripción a {{subscription_name}} se renovó por {{currency}} {{amount}}. Próxima renovación: {{next_billing_date}}.', + }, + subscription_cancelled: { + subject: 'Tu suscripción a {{subscription_name}} fue cancelada', + body: 'Tu suscripción a {{subscription_name}} ha sido cancelada. Mantendrás acceso hasta el {{access_until}}.', + }, + trial_ending: { + subject: 'Tu prueba de {{subscription_name}} termina el {{trial_end_date}}', + body: 'Tu prueba gratuita de {{subscription_name}} termina el {{trial_end_date}}. Añade un método de pago para mantener tu suscripción activa.', + }, + password_reset: { + subject: 'Restablece tu contraseña de {{merchant_name}}', + body: 'Recibimos una solicitud para restablecer tu contraseña. Este enlace caduca en {{expires_in_minutes}} minutos: {{reset_url}}', + }, + email_verification: { + subject: 'Verifica tu correo electrónico', + body: 'Confirma esta dirección para terminar de configurar tu cuenta: {{verification_url}}', + }, + }, + fr: { + receipt_payment_succeeded: { + subject: 'Reçu de votre paiement {{subscription_name}}', + body: 'Nous avons reçu votre paiement de {{currency}} {{amount}} pour {{subscription_name}} le {{paid_at}}.', + }, + invoice_due: { + subject: 'Votre facture {{subscription_name}} est due le {{due_date}}', + body: 'La facture {{subscription_name}} est due le {{due_date}}. Montant à payer : {{currency}} {{amount}}.', + }, + payment_failed: { + subject: 'Votre paiement pour {{subscription_name}} a échoué', + body: 'Nous n\u2019avons pas pu traiter votre paiement de {{currency}} {{amount}} pour {{subscription_name}}. Veuillez mettre à jour votre moyen de paiement.', + }, + subscription_renewed: { + subject: 'Votre abonnement {{subscription_name}} a été renouvelé', + body: 'Votre abonnement {{subscription_name}} a été renouvelé pour {{currency}} {{amount}}. Prochain renouvellement : {{next_billing_date}}.', + }, + subscription_cancelled: { + subject: 'Votre abonnement {{subscription_name}} a été annulé', + body: 'Votre abonnement {{subscription_name}} a été annulé. Vous conservez l\u2019accès jusqu\u2019au {{access_until}}.', + }, + trial_ending: { + subject: 'Votre essai de {{subscription_name}} se termine le {{trial_end_date}}', + body: 'Votre essai gratuit de {{subscription_name}} se termine le {{trial_end_date}}. Ajoutez un moyen de paiement pour conserver votre abonnement actif.', + }, + password_reset: { + subject: 'Réinitialisez votre mot de passe {{merchant_name}}', + body: 'Nous avons reçu une demande de réinitialisation de votre mot de passe. Ce lien expire dans {{expires_in_minutes}} minutes : {{reset_url}}', + }, + email_verification: { + subject: 'Vérifiez votre adresse e-mail', + body: 'Confirmez cette adresse pour finaliser la configuration de votre compte : {{verification_url}}', + }, + }, + ar: { + receipt_payment_succeeded: { + subject: 'إيصال دفع اشتراكك في {{subscription_name}}', + body: 'لقد استلمنا دفعتك بقيمة {{currency}} {{amount}} لاشتراك {{subscription_name}} في {{paid_at}}.', + }, + invoice_due: { + subject: 'فاتورة اشتراك {{subscription_name}} مستحقة في {{due_date}}', + body: 'فاتورة اشتراك {{subscription_name}} مستحقة في {{due_date}}. المبلغ المستحق: {{currency}} {{amount}}.', + }, + payment_failed: { + subject: 'فشل دفع اشتراك {{subscription_name}}', + body: 'تعذر معالجة دفعتك بقيمة {{currency}} {{amount}} لاشتراك {{subscription_name}}. يرجى تحديث طريقة الدفع.', + }, + subscription_renewed: { + subject: 'تم تجديد اشتراك {{subscription_name}}', + body: 'تم تجديد اشتراك {{subscription_name}} بقيمة {{currency}} {{amount}}. التجديد التالي: {{next_billing_date}}.', + }, + subscription_cancelled: { + subject: 'تم إلغاء اشتراك {{subscription_name}}', + body: 'تم إلغاء اشتراك {{subscription_name}}. سيبقى لديك الوصول حتى {{access_until}}.', + }, + trial_ending: { + subject: 'تنتهي فترة التجربة لـ {{subscription_name}} في {{trial_end_date}}', + body: 'تنتهي فترة التجربة المجانية لـ {{subscription_name}} في {{trial_end_date}}. أضف طريقة دفع للاحتفاظ باشتراكك نشطًا.', + }, + password_reset: { + subject: 'إعادة تعيين كلمة مرور {{merchant_name}}', + body: 'لقد تلقينا طلبًا لإعادة تعيين كلمة المرور. تنتهي صلاحية هذا الرابط خلال {{expires_in_minutes}} دقيقة: {{reset_url}}', + }, + email_verification: { + subject: 'تحقق من عنوان بريدك الإلكتروني', + body: 'قم بتأكيد هذا العنوان لإكمال إعداد حسابك: {{verification_url}}', + }, + }, +}; + +// ─── Service ─────────────────────────────────────────────────────────────────── + +export class LocalizedTemplateService { + private readonly catalog: LocaleCatalog; + private readonly defaultLocale: SupportedLocale; + + constructor(catalog: LocaleCatalog = DEFAULT_TEMPLATE_CATALOG, defaultLocale: SupportedLocale = DEFAULT_LOCALE) { + this.catalog = catalog; + this.defaultLocale = defaultLocale; + } + + /** Returns the supported locales that have at least one template defined. */ + listLocales(): SupportedLocale[] { + return (Object.keys(this.catalog) as SupportedLocale[]).filter( + (locale) => this.catalog[locale] && Object.keys(this.catalog[locale]).length > 0, + ); + } + + /** Returns the template ids available for every locale (union). */ + listTemplateIds(): LocalizedTemplateId[] { + const ids = new Set(); + for (const locale of Object.keys(this.catalog) as SupportedLocale[]) { + const block = this.catalog[locale]; + if (!block) continue; + for (const id of Object.keys(block) as LocalizedTemplateId[]) ids.add(id); + } + return [...ids]; + } + + /** + * Look up the localised copy for a template, falling back to the default + * locale per-template when the requested locale has no entry. + * + * @throws when neither the requested locale nor the default locale has the + * template — that's a configuration bug, not a runtime path. + */ + resolveCopy( + templateId: LocalizedTemplateId, + locale: SupportedLocale, + ): { copy: LocalizedCopy; resolvedLocale: SupportedLocale; fellBack: boolean } { + const requested = this.catalog[locale]?.[templateId]; + if (requested) { + return { copy: requested, resolvedLocale: locale, fellBack: locale !== this.defaultLocale ? false : false }; + } + const fallback = this.catalog[this.defaultLocale]?.[templateId]; + if (!fallback) { + throw new Error(`No template "${templateId}" found for locale "${locale}" or default "${this.defaultLocale}"`); + } + return { copy: fallback, resolvedLocale: this.defaultLocale, fellBack: true }; + } + + /** + * Render a localised template with the caller's variables. Missing variables + * are recorded but never leaked to the recipient — the placeholder is + * replaced with an empty string. + */ + renderLocalized( + templateId: LocalizedTemplateId, + locale: string | undefined, + variables: Record = {}, + ): RenderedTemplate { + const resolvedLocale = resolveLocale(locale); + const { copy, resolvedLocale: usedLocale, fellBack } = this.resolveCopy(templateId, resolvedLocale); + + const subject = substituteVariables(copy.subject, variables); + const body = substituteVariables(copy.body, variables); + + const missingVariables = [...new Set([...subject.missing, ...body.missing])]; + + return { + templateId, + resolvedLocale: usedLocale, + fellBack, + subject: subject.text, + body: body.text, + missingVariables, + }; + } +} + +/** Default singleton — uses the bundled catalog and `en` fallback. */ +export const localizedTemplateService = new LocalizedTemplateService(); diff --git a/backend/services/notification/notificationRetryService.ts b/backend/services/notification/notificationRetryService.ts new file mode 100644 index 00000000..68857bb3 --- /dev/null +++ b/backend/services/notification/notificationRetryService.ts @@ -0,0 +1,231 @@ +/** + * Notification retry service with backoff and dead-lettering (#1753) + * + * Notification sends (push, email, SMS, in-app) used to be fire-and-forget: + * a transport that threw was logged and dropped. This service wraps a + * transport with exponential backoff retries, a dead-letter queue for + * permanent failures, and a metrics snapshot so dashboards can surface the + * failure patterns that used to be invisible. + * + * It reuses the shared `RetryPolicy` (full-jitter exponential backoff) and + * `DeadLetterQueue` from `backend/shared/queue` so behaviour matches the rest + * of the platform's job pipeline. + * + * Usage + * ───── + * const retryService = new NotificationRetryService(emailTransport, { + * channel: 'email', + * retryPolicy: new RetryPolicy({ maxAttempts: { normal: 5 } }), + * }); + * const result = await retryService.send({ recipient, subject, body }); + * if (result.status === 'dead_lettered') { /* alert ops *\/ } + * const metrics = retryService.getMetrics(); + */ + +import { RetryPolicy } from '../../shared/queue/retryPolicy'; +import { DeadLetterQueue } from '../../shared/queue/deadLetterQueue'; +import type { PriorityClass } from '../../shared/queue/types'; + +/** A message handed to a channel transport. Mirrors ChannelTransport's payload. */ +export interface NotificationMessage { + recipient: string; + subject?: string; + body: string; + /** Arbitrary metadata for tracing / correlation. */ + metadata?: Record; +} + +export type NotificationChannel = 'push' | 'email' | 'sms' | 'in_app'; + +/** + * A transport sends one message on one channel and resolves to `true` when + * the downstream provider accepted it. Resolving `false` or throwing counts + * as a failure eligible for retry. + */ +export type NotificationTransport = (message: NotificationMessage) => Promise; + +export type SendOutcome = 'delivered' | 'failed' | 'dead_lettered'; + +export interface SendResult { + status: SendOutcome; + attempts: number; + /** Total wall-clock time spent on the send, including backoff sleeps. */ + elapsedMs: number; + /** Present when the send failed or was dead-lettered. */ + error?: string; + /** DLQ entry id when `status === 'dead_lettered'`. */ + deadLetterEntryId?: string; +} + +export interface NotificationRetryMetrics { + channel: NotificationChannel; + totalSendAttempts: number; + delivered: number; + failed: number; + deadLettered: number; + retryCount: number; + /** Average attempts across all send calls (1 = first-try success). */ + avgAttempts: number; + lastDeliveredAt: number | null; + lastFailedAt: number | null; + lastError: string | null; + dlqDepth: number; +} + +export interface NotificationRetryConfig { + channel: NotificationChannel; + /** Defaults to a fresh RetryPolicy. */ + retryPolicy?: RetryPolicy; + /** Priority class used to look up max attempts. Default: 'normal'. */ + priority?: PriorityClass; + /** Defaults to a new in-memory DeadLetterQueue. */ + deadLetterQueue?: DeadLetterQueue; + /** Injected sleep for tests. Defaults to setTimeout. */ + sleep?: (ms: number) => Promise; + /** Injected clock for tests. Defaults to Date.now. */ + now?: () => number; +} + +const DEFAULT_SLEEP: (ms: number) => Promise = (ms) => + new Promise((resolve) => setTimeout(resolve, ms)); + +export class NotificationRetryService { + private readonly transport: NotificationTransport; + private readonly channel: NotificationChannel; + private readonly retryPolicy: RetryPolicy; + private readonly priority: PriorityClass; + private readonly dlq: DeadLetterQueue; + private readonly sleep: (ms: number) => Promise; + private readonly now: () => number; + + // Metrics counters + private totalSendAttempts = 0; + private delivered = 0; + private failed = 0; + private deadLettered = 0; + private retryCount = 0; + private totalAttemptsAcrossSends = 0; + private lastDeliveredAt: number | null = null; + private lastFailedAt: number | null = null; + private lastError: string | null = null; + + constructor(transport: NotificationTransport, config: NotificationRetryConfig) { + this.transport = transport; + this.channel = config.channel; + this.retryPolicy = config.retryPolicy ?? new RetryPolicy(); + this.priority = config.priority ?? 'normal'; + this.dlq = config.deadLetterQueue ?? new DeadLetterQueue({ maxEntries: 2_000 }); + this.sleep = config.sleep ?? DEFAULT_SLEEP; + this.now = config.now ?? Date.now; + } + + /** + * Send a notification, retrying transient failures with exponential backoff. + * Permanent failures (after exhausting retries) are dead-lettered. + */ + async send(message: NotificationMessage): Promise { + const startedAt = this.now(); + let attempt = 0; + let lastError: string | undefined; + + // attempt = number of attempts already made (0-indexed for RetryPolicy.decide) + while (true) { + attempt += 1; + this.totalSendAttempts += 1; + this.totalAttemptsAcrossSends += 1; + + try { + const accepted = await this.transport(message); + if (accepted) { + this.delivered += 1; + this.lastDeliveredAt = this.now(); + this.lastError = null; + return { + status: 'delivered', + attempts: attempt, + elapsedMs: this.now() - startedAt, + }; + } + // Transport resolved false — treat as a recoverable failure. + lastError = 'Transport rejected the message'; + } catch (err) { + lastError = err instanceof Error ? err.message : 'Notification transport threw'; + } + + // Ask the retry policy whether to retry and how long to wait. + // `attempt` here is the number of attempts already made, which is what + // RetryPolicy.decide expects (0 = first failure). + const decision = this.retryPolicy.decide(this.priority, attempt, new Error(lastError)); + if (!decision.shouldRetry) { + // Permanently failed — dead-letter it. + const entry = await this.dlq.add( + { + id: `notif:${this.channel}:${startedAt}:${Math.random().toString(36).slice(2, 8)}`, + name: `notification:${this.channel}`, + data: { message, lastError }, + priority: this.priority, + enqueuedAt: startedAt, + }, + lastError, + attempt, + ); + this.failed += 1; + this.deadLettered += 1; + this.lastFailedAt = this.now(); + this.lastError = lastError; + return { + status: 'dead_lettered', + attempts: attempt, + elapsedMs: this.now() - startedAt, + error: lastError, + deadLetterEntryId: entry.id, + }; + } + + // Backoff before the next attempt. + this.retryCount += 1; + await this.sleep(decision.delayMs); + } + } + + /** Snapshot of delivery metrics for dashboards and alerting. */ + getMetrics(): NotificationRetryMetrics { + const totalSends = this.delivered + this.failed; + return { + channel: this.channel, + totalSendAttempts: this.totalSendAttempts, + delivered: this.delivered, + failed: this.failed, + deadLettered: this.deadLettered, + retryCount: this.retryCount, + avgAttempts: totalSends > 0 ? this.totalAttemptsAcrossSends / totalSends : 0, + lastDeliveredAt: this.lastDeliveredAt, + lastFailedAt: this.lastFailedAt, + lastError: this.lastError, + dlqDepth: this.dlq.depth, + }; + } + + /** Expose the DLQ for inspection / manual replay. */ + getDeadLetterQueue(): DeadLetterQueue { + return this.dlq; + } + + /** List dead-lettered entries for this channel's dashboard. */ + listDeadLetters() { + return this.dlq.getEntries(); + } + + /** Reset all counters (useful for tests). Does not clear the DLQ. */ + resetMetrics(): void { + this.totalSendAttempts = 0; + this.delivered = 0; + this.failed = 0; + this.deadLettered = 0; + this.retryCount = 0; + this.totalAttemptsAcrossSends = 0; + this.lastDeliveredAt = null; + this.lastFailedAt = null; + this.lastError = null; + } +} diff --git a/backend/services/notification/webhook.ts b/backend/services/notification/webhook.ts index ff74df8f..ec7468bc 100644 --- a/backend/services/notification/webhook.ts +++ b/backend/services/notification/webhook.ts @@ -22,7 +22,13 @@ export interface RegisterWebhookInput { merchantId: string; url: string; events: WebhookEventType[]; - secretKey: string; + /** + * Signing secret. Required when `signingEnabled` is true (default). Partners + * that opt out of signing leave this undefined and set `signingEnabled: false`. + */ + secretKey?: string; + /** When false, deliveries are sent unsigned. Default: true. Closes #1751. */ + signingEnabled?: boolean; retryPolicy?: Partial; rateLimitPerMinute?: number; rateLimit?: WebhookRateLimitConfig; @@ -126,12 +132,14 @@ export const isWebhookEventAllowed = ( /** Returns true if `signature` matches any signing secret currently valid for `webhook`. */ export const verifyWebhookSignatureAny = ( - webhook: Pick, + webhook: Pick, signature: string, payload: WebhookEventPayload, at: number = Date.now() ): boolean => { - const secrets = webhook.secrets?.length ? webhook.secrets : [{ key: webhook.secretKey, createdAt: at, validFrom: at }]; + // Unsigned webhooks have no secrets to verify against. + if (webhook.signingEnabled === false) return false; + const secrets = webhook.secrets?.length ? webhook.secrets : webhook.secretKey ? [{ key: webhook.secretKey, createdAt: at, validFrom: at }] : []; return secrets.some((secret) => { if (at < secret.validFrom) return false; if (secret.validUntil !== undefined && at > secret.validUntil) return false; @@ -157,13 +165,22 @@ export class WebhookDeliveryService { registerWebhook(input: RegisterWebhookInput): WebhookConfig { const id = createId('whk'); const createdAt = now(); + const signingEnabled = input.signingEnabled ?? true; + // When signing is enabled a secret is required; when disabled, no secret + // is stored and outbound deliveries skip the signature header. + if (signingEnabled && !input.secretKey) { + throw new Error('secretKey is required when signingEnabled is true'); + } const config: WebhookConfig = { id, merchantId: input.merchantId, url: input.url, events: [...input.events], - secretKey: input.secretKey, - secrets: [{ key: input.secretKey, createdAt, validFrom: createdAt }], + secretKey: signingEnabled ? input.secretKey : undefined, + secrets: signingEnabled && input.secretKey + ? [{ key: input.secretKey, createdAt, validFrom: createdAt }] + : [], + signingEnabled, retryPolicy: clampRetryPolicy(input.retryPolicy), rateLimitPerMinute: input.rateLimitPerMinute, rateLimit: input.rateLimit, @@ -188,11 +205,18 @@ export class WebhookDeliveryService { const existing = this.webhooks.get(id); if (!existing) throw new Error(`Webhook ${id} not found`); + const signingEnabled = input.signingEnabled ?? existing.signingEnabled; + if (signingEnabled && !input.secretKey && !existing.secretKey) { + throw new Error('secretKey is required when signingEnabled is true'); + } + + const secretKey = input.secretKey ?? existing.secretKey; const next: WebhookConfig = { ...existing, url: input.url ?? existing.url, events: input.events ? [...input.events] : existing.events, - secretKey: input.secretKey ?? existing.secretKey, + signingEnabled, + secretKey: signingEnabled ? secretKey : undefined, retryPolicy: clampRetryPolicy(input.retryPolicy ?? existing.retryPolicy), rateLimitPerMinute: input.rateLimitPerMinute ?? existing.rateLimitPerMinute, rateLimit: input.rateLimit ?? existing.rateLimit, @@ -423,7 +447,9 @@ export class WebhookDeliveryService { if (!isWebhookEventAllowed(webhook, input.eventType)) return null; const payload = buildWebhookPayload(input); - const signature = signWebhookPayload(payload, webhook.secretKey); + const signature = webhook.signingEnabled && webhook.secretKey + ? signWebhookPayload(payload, webhook.secretKey) + : undefined; const idempotencyKey = input.idempotencyKey ?? `${payload.id}:${webhook.id}`; if (this.isWithinIdempotencyWindow(idempotencyKey)) { const delivery: WebhookDelivery = { @@ -537,7 +563,9 @@ export class WebhookDeliveryService { ...(customPayload as Partial), }; - const signature = signWebhookPayload(testPayload, webhook.secretKey); + const signature = webhook.signingEnabled && webhook.secretKey + ? signWebhookPayload(testPayload, webhook.secretKey) + : undefined; const idempotencyKey = `test:${testId}`; const delivery: WebhookDelivery = { @@ -607,11 +635,14 @@ export class WebhookDeliveryService { const headers: Record = { 'Content-Type': 'application/json', - 'X-SubTrackr-Signature': delivery.signature, 'X-SubTrackr-Event-Type': delivery.eventType, 'X-SubTrackr-Event-Id': delivery.eventId, [WEBHOOK_IDEMPOTENCY_HEADER]: delivery.idempotencyKey, }; + // Only attach the signature header when signing is enabled for this webhook. + if (delivery.signature) { + headers['X-SubTrackr-Signature'] = delivery.signature; + } if (payloadTruncated) { headers['X-SubTrackr-Payload-Truncated'] = 'true'; headers['X-SubTrackr-Payload-Hash'] = payloadHash as string; diff --git a/src/store/webhookStore.ts b/src/store/webhookStore.ts index 920e34c6..1654ecbe 100644 --- a/src/store/webhookStore.ts +++ b/src/store/webhookStore.ts @@ -118,6 +118,7 @@ export const useWebhookStore = create()( const webhook: WebhookConfig = { ...input, secretKey, + signingEnabled: true, secrets: [{ key: secretKey, createdAt, validFrom: createdAt }], id: createId('whk'), createdAt, diff --git a/src/types/webhook.ts b/src/types/webhook.ts index fb81a8ff..29633294 100644 --- a/src/types/webhook.ts +++ b/src/types/webhook.ts @@ -87,9 +87,17 @@ export interface WebhookConfig { merchantId: string; url: string; events: WebhookEventType[]; - secretKey: string; + /** + * Signing secret. When `signingEnabled` is true (default) this must be set and + * is used to compute the `X-SubTrackr-Signature` header on outbound deliveries. + * Partners that do not want signing can register a webhook with + * `signingEnabled: false` and leave `secretKey` undefined. + */ + secretKey?: string; /** Signing secret history; the most recently added entry is the active signing secret. */ secrets: WebhookSecret[]; + /** When false, outbound deliveries are sent unsigned (partner opt-out). Default: true. */ + signingEnabled: boolean; retryPolicy: WebhookRetryPolicy; /** @deprecated use rateLimit.steadyPerMinute instead. Kept for backward compatibility. */ rateLimitPerMinute?: number; @@ -179,7 +187,8 @@ export interface WebhookDelivery { nextRetryAt?: number; responseCode?: number; errorMessage?: string; - signature: string; + /** HMAC signature header value. Absent on unsigned deliveries (signingEnabled=false). */ + signature?: string; idempotencyKey: string; latencyMs?: number; /** Truncated preview of the request body sent, for delivery logs/receipts. */