fix(notifications): P2 extreme-audit P1 fixes before deploy

Independent audit verdict was NOT SAFE; all findings fixed:
- dispatcher Idempotency-Key is now per-attempt (${id}:${attempts}),
  not row id alone, so a transient-failure response cached by Resend
  cannot permanently suppress every retry and silently lose an email.
- enqueueDues is fully try/catch wrapped so a notifications-table or
  render failure can never throw into / wedge the Stripe money path.
- dues_lapsed dedupe falls back to the subscription id when periodEnd
  is null (canceled/deleted subs), so future lapses no longer collapse
  to one key and get suppressed. New test; suite 499.
- documented the dispatcher's once/min concurrency assumption.
This commit is contained in:
Moheeb Zara
2026-05-17 05:06:02 -07:00
parent 7eb83676e7
commit f1bae44afd
5 changed files with 125 additions and 44 deletions
+16 -2
View File
@@ -37,12 +37,26 @@ export function notificationDedupeKey(
// Renewal + payment-failed are keyed by the Stripe invoice (one mail per
// invoice outcome). Lapse is keyed by member + the period it lapsed for, so a
// member can lapse again next cycle but not be re-mailed for the same one.
// periodEnd can be absent (canceled/deleted subscriptions carry no item
// current_period_end); without a fallback every lapse would collapse to
// `dues_lapsed:<member>` and suppress every future lapse notice. Fall back to
// the subscription id (a new comeback subscription has a new id, so distinct
// lapses stay distinct).
export function duesDedupeKey(
type: DuesNotificationType,
ref: { invoiceId?: string | null; memberId?: string | null; periodEnd?: string | null },
ref: {
invoiceId?: string | null
memberId?: string | null
periodEnd?: string | null
subscriptionId?: string | null
},
): string {
if (type === 'dues_lapsed') {
return notificationDedupeKey(['dues_lapsed', ref.memberId, ref.periodEnd])
return notificationDedupeKey([
'dues_lapsed',
ref.memberId,
ref.periodEnd || ref.subscriptionId,
])
}
return notificationDedupeKey([type, ref.invoiceId])
}