Skip to content

feat(stripe): handle payment failures with email, tracking and backoff - #1407

Merged
steebchen merged 5 commits into
mainfrom
terragon/handle-stripe-card-error-email-w2n6gu
Jan 14, 2026
Merged

steebchen merged 5 commits into
mainfrom
terragon/handle-stripe-card-error-email-w2n6gu

Conversation

@steebchen

@steebchen steebchen commented Jan 5, 2026

Copy link
Copy Markdown
Member

Summary

  • Sends payment failure emails to an organization when a Stripe payment fails, with detailed error information and actionable guidance
  • Introduces exponential backoff and tracking for payment failures (paymentFailureCount, lastPaymentFailureAt) to avoid spamming on repeated failures
  • Adds new HTML email template generated by generatePaymentFailureEmailHtml, including error details, amount, and tailored action messaging
  • Updates logs and transaction/organization state to reflect failure context and backoff status
  • Resets failure metrics on successful payments

Changes

Backend

  • apps/api/src/stripe.ts
    • Extract last_payment_error details: message, code, decline_code
    • Compute totalAmountInDollars for the failed charge
    • Send email to organization.billingEmail with subject "Payment Failed - Action Required"
    • Email HTML generated by generatePaymentFailureEmailHtml(organization.name, details)
    • Logs success/failure of sent emails and updates transaction descriptions with the errorMessage
    • Updates payment failure tracking on each failure: paymentFailureCount and lastPaymentFailureAt, enabling exponential backoff
    • Implements exponential backoff logic to determine whether to send the email immediately or skip until the next window
    • Resets paymentFailureCount and lastPaymentFailureAt when a payment eventually succeeds (to start fresh)
    • On email delivery, logs success; on failure, logs error without crashing

Email Utilities

  • apps/api/src/utils/email.ts
    • Added PaymentFailureDetails interface
    • Implemented generatePaymentFailureEmailHtml(organizationName, details)
    • Escapes HTML inputs and formats amount with currency when provided
    • Determines user-facing actionMessage based on declineCode and errorCode (e.g., insufficient_funds, expired_card, lost_card/stolen_card)
    • Returns a complete, styled HTML email including error details, amount, currency, and a CTA to update payment method

Worker

  • apps/worker/src/worker.ts
    • Adjusted auto top-up flow to respect backoff: checks lastPaymentFailureAt and paymentFailureCount to skip retries during backoff windows
    • Keeps existing pending transaction checks but adds a backoff gate so we don’t retry too aggressively when failures occur

Database

  • Updated schema to support backoff tracking
    • packages/db/src/schema.ts: added paymentFailureCount: integer not null default 0 and lastPaymentFailureAt: timestamp()
  • Updated types to reflect new fields
    • packages/db/src/types.ts: SerializedOrganization now excludes paymentFailureCount and lastPaymentFailureAt from certain representations but those fields exist on the DB level

Validation / Testing

  • Trigger a Stripe payment failure and verify:
    • The associated transaction is updated to failed with the correct errorMessage
    • An email is sent to organization.billingEmail with subject "Payment Failed - Action Required"
    • Email content includes:
      • Error message
      • Amount (currency and value) when available
      • Action guidance tailored to decline_code/error_code
    • Logs include the errorMessage and confirmation of email delivery
  • Validate exponential backoff behavior by simulating multiple consecutive failures:
    • First failure should send email immediately
    • Subsequent failures should respect backoff windows (e.g., 1h, 2h, 4h, etc.) and skip email during backoff periods
  • Manually test different decline codes (insufficient_funds, expired_card, lost_card/stolen_card) to ensure the actionMessage changes accordingly
  • Verify worker backoff logic prevents new top-ups during backoff periods for the same organization

Notes

  • Migrations added to support backoff tracking:
    • packages/db/src/schema.ts updated to add paymentFailureCount and lastPaymentFailureAt
    • packages/db/migrations/1767656749_broken_stranger.sql added to migrate existing data
    • packages/db/migrations/meta/1767656749_snapshot.json and _journal.json updated to reflect migration
  • DB schema now includes backoff-tracking fields (paymentFailureCount and lastPaymentFailureAt)
  • Task: https://www.terragonlabs.com/task/45ea62cf-fd15-4f22-b95c-54158f13257c

Summary by CodeRabbit

  • New Features

    • Payment failure emails now include detailed error info (message, codes, decline reason, amount) and use a polished HTML layout.
    • Automatic top-up retries now follow an exponential backoff schedule (1h, 2h, 4h, 8h, 16h, 24h cap).
  • Bug Fixes

    • Successful payments reset failure tracking so normal operations resume.
  • Tests / Chores

    • Tests and database schema updated to track failure count and last failure timestamp for organizations.

✏️ Tip: You can customize this high-level summary in your review settings.

…ilure

- Extract detailed error info from Stripe payment intent failures
- Introduce generatePaymentFailureEmailHtml util to compose email content
- Send transactional email to organization's billing email on payment failures
- Enhance logging around payment failure handling
- Improve failure descriptions stored in transaction records

Co-authored-by: terragon-labs[bot] <terragon-labs[bot]@users.noreply.github.com>
Copilot AI review requested due to automatic review settings January 5, 2026 17:35
@coderabbitai

coderabbitai Bot commented Jan 5, 2026

Copy link
Copy Markdown
Contributor

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Adds organization-level payment-failure tracking and exponential backoff for retries, generates and sends payment-failure emails with extracted Stripe error details, updates worker top-up logic to respect backoff, and persists two new columns on the organization table; tests and types updated accordingly.

Changes

Cohort / File(s) Summary
Email template generation
apps/api/src/utils/email.ts
Added PaymentFailureDetails interface and generatePaymentFailureEmailHtml() that escapes inputs, formats optional amount, selects action messaging based on decline/error codes, and returns an HTML email body.
Stripe webhook & email integration
apps/api/src/stripe.ts
handlePaymentIntentSucceeded resets paymentFailureCount/lastPaymentFailureAt; handlePaymentIntentFailed extracts errorMessage, errorCode, declineCode, updates org failure counters, applies exponential backoff to decide email sends, and sends payment-failure emails using the new template.
Worker top-up backoff
apps/worker/src/worker.ts
Replaced prior skip logic with a 1-hour pending-check plus exponential backoff skip using org paymentFailureCount/lastPaymentFailureAt (intervals: 1h, 2h, 4h, 8h, 16h, 24h cap).
DB schema & types
packages/db/src/schema.ts, packages/db/src/types.ts, packages/db/migrations/1767656749_broken_stranger.sql, packages/db/migrations/meta/_journal.json
Added paymentFailureCount (integer, NOT NULL, DEFAULT 0) and lastPaymentFailureAt (timestamp) to organization; updated SerializedOrganization to omit these fields; migration and journal entry added.
Tests / Fixtures
apps/gateway/src/lib/rate-limit.spec.ts
Test fixtures updated to include paymentFailureCount: 0 and lastPaymentFailureAt: null in mocked organization objects.

Sequence Diagram(s)

sequenceDiagram
    %% New/changed flow: Stripe webhook → API → DB → Email generator → Email service
    participant Stripe as Stripe Webhook
    participant API as API Handler
    participant DB as Database / Org Record
    participant EmailGen as Email Generator
    participant EmailSvc as Email Service

    Note over Stripe,API: payment_intent.failed flow
    Stripe->>API: POST webhook (payment_intent.failed)
    activate API
    API->>API: Extract last_payment_error → errorMessage,errorCode,declineCode
    API->>DB: Fetch org, increment paymentFailureCount, set lastPaymentFailureAt
    DB-->>API: Updated org record
    API->>API: Evaluate backoff (now vs nextRetry)
    alt send email
      API->>EmailGen: generatePaymentFailureEmailHtml(org.name, details)
      EmailGen-->>API: HTML body
      API->>EmailSvc: Send email to billing contact
      EmailSvc-->>API: Delivery result
    else skip due to backoff
      API-->>API: Log skipping email (backoff)
    end
    API->>API: Log outcome (errorMessage, failureCount)
    deactivate API
Loading
sequenceDiagram
    %% Worker top-up with backoff: Worker → DB → Billing → DB
    participant Worker as Top-up Worker
    participant DB as Database / Org Record
    participant Billing as Billing/Payments

    Note over Worker,DB: Scheduled top-up loop
    Worker->>DB: Load organizations needing top-up
    DB-->>Worker: Org list (includes paymentFailureCount,lastPaymentFailureAt)
    loop per organization
      Worker->>Worker: Check recent pending within 1h
      alt pending within 1h
        Worker-->>Worker: Skip due to recent pending
      else not pending
        Worker->>Worker: Compute nextRetry = lastPaymentFailureAt + backoff(paymentFailureCount)
        alt now < nextRetry
          Worker-->>Worker: Skip due to exponential backoff
        else now >= nextRetry
          Worker->>Billing: Attempt top-up/charge
          Billing-->>Worker: Success / Failure
          alt failure
            Worker->>DB: Increment paymentFailureCount, set lastPaymentFailureAt
          else success
            Worker->>DB: Reset paymentFailureCount, clear lastPaymentFailureAt
          end
        end
      end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

auto-merge

Suggested reviewers

  • smakosh
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(stripe): handle payment failures with email, tracking and backoff' accurately summarizes the main changes: adding payment failure email notifications, tracking failures via paymentFailureCount and lastPaymentFailureAt, and implementing exponential backoff logic to prevent email spam.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings


📜 Recent review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 60e001b and 9ae245d.

📒 Files selected for processing (2)
  • apps/api/src/stripe.ts
  • apps/api/src/utils/email.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/api/src/utils/email.ts
  • apps/api/src/stripe.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (10)
  • GitHub Check: lint / run
  • GitHub Check: generate / run
  • GitHub Check: build / run
  • GitHub Check: test / run
  • GitHub Check: e2e-shards (3)
  • GitHub Check: e2e-shards (5)
  • GitHub Check: e2e-shards (2)
  • GitHub Check: e2e-shards (4)
  • GitHub Check: autofix
  • GitHub Check: e2e-shards (1)

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot changed the title Handle Stripe payment failure: email organization on failure feat(stripe): add payment failure email Jan 5, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Fix all issues with AI Agents 🤖
In @apps/api/src/utils/email.ts:
- Around line 266-269: The formattedAmount concatenation uses details.currency
directly; sanitize/escape details.currency before inserting into HTML by passing
it through an HTML-escaping utility (e.g., htmlEscape or escapeHtml) or by
normalizing/validating against allowed ISO currency codes, then build
formattedAmount using the escaped/validated value (refer to the formattedAmount
variable and the later HTML insertion where formattedAmount is used) so that no
unescaped external data is injected into the template.
🧹 Nitpick comments (1)
apps/api/src/utils/email.ts (1)

337-337: Unnecessary escaping of hardcoded string.

The actionMessage variable is a hardcoded string defined within lines 271–286, so escaping it on line 337 is redundant. While harmless, removing this call would marginally improve readability and performance.

🔎 Proposed fix
 								<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 1.6; color: #333333;">
-									${escapeHtml(actionMessage)}
+									${actionMessage}
 								</p>
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3eca4c3 and 6296da0.

📒 Files selected for processing (2)
  • apps/api/src/stripe.ts
  • apps/api/src/utils/email.ts
🧰 Additional context used
📓 Path-based instructions (7)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Never use any or as any unless absolutely necessary in TypeScript code
For database reads: Use db().query.<table>.findMany() or db().query.<table>.findFirst()

Files:

  • apps/api/src/utils/email.ts
  • apps/api/src/stripe.ts
**/*.{ts,tsx,js,jsx,json,md}

📄 CodeRabbit inference engine (CLAUDE.md)

Always use tabs for indentation

Files:

  • apps/api/src/utils/email.ts
  • apps/api/src/stripe.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx,js,jsx}: Always use top-level import, never use require or dynamic imports
No unnecessary code comments

Files:

  • apps/api/src/utils/email.ts
  • apps/api/src/stripe.ts
apps/{gateway,api}/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use Hono framework with Zod validation and OpenAPI documentation for backend APIs

Files:

  • apps/api/src/utils/email.ts
  • apps/api/src/stripe.ts
**/*.{js,ts,tsx,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Always use top-level import, never use require or dynamic imports

Files:

  • apps/api/src/utils/email.ts
  • apps/api/src/stripe.ts
{apps/api,apps/gateway,packages/db}/**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

{apps/api,apps/gateway,packages/db}/**/*.ts: Use Drizzle ORM with latest object syntax for database operations
For database reads: Use db().query.<table>.findMany() or db().query.<table>.findFirst()

Files:

  • apps/api/src/utils/email.ts
  • apps/api/src/stripe.ts
apps/{gateway,api}/src/**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

apps/{gateway,api}/src/**/*.ts: Use Hono for backend framework in Gateway and API services
Use Zod schemas for validation in Hono services

Files:

  • apps/api/src/utils/email.ts
  • apps/api/src/stripe.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
  • GitHub Check: Agent
  • GitHub Check: generate / run
  • GitHub Check: test / run
  • GitHub Check: build / run
  • GitHub Check: lint / run
  • GitHub Check: autofix
🔇 Additional comments (5)
apps/api/src/utils/email.ts (1)

251-257: LGTM!

The interface definition is clear and well-structured, with appropriate optional fields for Stripe payment error details.

apps/api/src/stripe.ts (4)

11-11: LGTM!

Import statement correctly brings in the new payment failure email template function.


614-618: LGTM!

Error detail extraction is well-implemented with appropriate optional chaining and a sensible default value for the error message.


628-628: LGTM!

Consistent use of the extracted errorMessage in transaction descriptions improves code clarity and provides better error context for debugging.

Also applies to: 651-651, 664-664


668-687: The code is correct as-is. The organization.billingEmail is guaranteed to be present by the database schema (billingEmail: text().notNull()), so no guard clause is needed. The error handling is appropriate—catching and logging email failures without breaking the webhook is the right approach.

Comment thread apps/api/src/utils/email.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request implements automatic email notifications for Stripe payment failures, enhancing customer communication by proactively informing organizations when payments fail and providing clear next steps.

Key Changes:

  • Adds a new HTML email template (generatePaymentFailureEmailHtml) with error details, amount information, and context-aware action messages based on decline/error codes
  • Integrates payment failure email sending into the Stripe webhook handler, sending notifications to organization.billingEmail when payment intents fail
  • Extracts and standardizes error information from Stripe's last_payment_error for consistent logging and user-facing messages

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.

File Description
apps/api/src/utils/email.ts Adds PaymentFailureDetails interface and generatePaymentFailureEmailHtml function that generates a styled HTML email with error details, conditional action messages based on decline codes (insufficient_funds, expired_card, lost_card, stolen_card), and formatted amount display
apps/api/src/stripe.ts Updates handlePaymentIntentFailed to extract error details from Stripe, send failure emails to organization billing contacts, and use consistent error messages throughout transaction descriptions and logging

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apps/api/src/stripe.ts Outdated
Comment on lines +675 to +676
errorCode: errorCode ?? undefined,
declineCode: declineCode ?? undefined,

Copilot AI Jan 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nullish coalescing operator with undefined conversion (errorCode ?? undefined) is redundant. When errorCode is null or undefined, the nullish coalescing operator will return undefined, which is the same as just passing errorCode directly since the PaymentFailureDetails interface already accepts optional string fields. The same applies to declineCode ?? undefined. These conversions can be simplified to just errorCode and declineCode.

Suggested change
errorCode: errorCode ?? undefined,
declineCode: declineCode ?? undefined,
errorCode,
declineCode,

Copilot uses AI. Check for mistakes.
<p style="margin: 0; font-size: 14px; color: #7f1d1d;">
${escapedErrorMessage}
</p>
${formattedAmount ? `<p style="margin: 10px 0 0 0; font-size: 14px; color: #7f1d1d;">Amount: ${formattedAmount}</p>` : ""}

Copilot AI Jan 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The formattedAmount variable is not HTML-escaped before being interpolated into the email template at line 333. While the amount itself is a number and currency is converted to uppercase, it's a best practice to escape all user-controlled or external data before inserting it into HTML to prevent potential XSS vulnerabilities. The currency string comes from Stripe's paymentIntent.currency which should be safe, but defense-in-depth suggests escaping this value.

Suggested change
${formattedAmount ? `<p style="margin: 10px 0 0 0; font-size: 14px; color: #7f1d1d;">Amount: ${formattedAmount}</p>` : ""}
${formattedAmount ? `<p style="margin: 10px 0 0 0; font-size: 14px; color: #7f1d1d;">Amount: ${escapeHtml(formattedAmount)}</p>` : ""}

Copilot uses AI. Check for mistakes.
Comment thread apps/api/src/utils/email.ts Outdated
const escapedErrorMessage = escapeHtml(details.errorMessage);

const formattedAmount =
details.amount && details.currency

Copilot AI Jan 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition details.amount && details.currency will evaluate to false when the amount is 0, preventing the display of zero-dollar amounts in the email. While zero-dollar payment failures may be unusual, if they can occur, they should still be displayed to the user. Consider using details.amount !== undefined && details.amount !== null && details.currency to explicitly check for the presence of the amount value rather than its truthiness.

Suggested change
details.amount && details.currency
details.amount !== undefined &&
details.amount !== null &&
details.currency

Copilot uses AI. Check for mistakes.
Comment on lines +251 to +385
export interface PaymentFailureDetails {
errorMessage: string;
errorCode?: string;
declineCode?: string;
amount?: number;
currency?: string;
}

export function generatePaymentFailureEmailHtml(
organizationName: string,
details: PaymentFailureDetails,
): string {
const escapedOrgName = escapeHtml(organizationName);
const escapedErrorMessage = escapeHtml(details.errorMessage);

const formattedAmount =
details.amount && details.currency
? `${details.currency} ${details.amount.toFixed(2)}`
: null;

let actionMessage = "Please update your payment method and try again.";
if (details.declineCode === "insufficient_funds") {
actionMessage =
"Please ensure your card has sufficient funds or use a different payment method.";
} else if (
details.declineCode === "expired_card" ||
details.errorCode === "expired_card"
) {
actionMessage = "Your card has expired. Please update your payment method.";
} else if (
details.declineCode === "lost_card" ||
details.declineCode === "stolen_card"
) {
actionMessage =
"This card cannot be used. Please add a different payment method.";
}

return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payment Failed - LLMGateway</title>
</head>
<body
style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #ffffff;"
>
<table role="presentation" style="width: 100%; border-collapse: collapse;">
<tr>
<td align="center" style="padding: 40px 20px;">
<table role="presentation" style="max-width: 600px; width: 100%; border-collapse: collapse;">
<!-- Header -->
<tr>
<td
style="background-color: #dc2626; padding: 40px 30px; text-align: center; border-radius: 8px 8px 0 0;"
>
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600;">Payment Failed</h1>
</td>
</tr>

<!-- Main Content -->
<tr>
<td style="background-color: #f8f9fa; padding: 40px 30px; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 1.6; color: #333333;">
Hi there,
</p>

<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 1.6; color: #333333;">
We were unable to process a payment for <strong>${escapedOrgName}</strong>.
</p>

<!-- Error Details Box -->
<div
style="background-color: #fef2f2; border: 1px solid #fecaca; border-radius: 6px; padding: 20px; margin-bottom: 20px;"
>
<p style="margin: 0 0 10px 0; font-size: 14px; font-weight: 600; color: #991b1b;">
Error Details:
</p>
<p style="margin: 0; font-size: 14px; color: #7f1d1d;">
${escapedErrorMessage}
</p>
${formattedAmount ? `<p style="margin: 10px 0 0 0; font-size: 14px; color: #7f1d1d;">Amount: ${formattedAmount}</p>` : ""}
</div>

<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 1.6; color: #333333;">
${escapeHtml(actionMessage)}
</p>

<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 1.6; color: #333333;">
To ensure uninterrupted service, please update your payment information as soon as possible.
</p>

<!-- CTA Button -->
<table role="presentation" style="width: 100%; border-collapse: collapse;">
<tr>
<td align="center" style="padding: 10px 0;">
<a
href="https://llmgateway.io/dashboard/settings/org/billing"
style="display: inline-block; background-color: #000000; color: #ffffff; padding: 14px 40px; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 16px;"
>Update Payment Method</a>
</td>
</tr>
</table>

<p style="margin: 30px 0 0 0; font-size: 14px; line-height: 1.6; color: #666666;">
If you believe this is an error or need assistance, please reply to this email and we'll be happy to
help.
</p>
</td>
</tr>

<!-- Footer -->
<tr>
<td
style="padding: 30px 40px; background-color: #f8f9fa; border-radius: 0 0 8px 8px; border-top: 1px solid #e9ecef;"
>
<p style="margin: 0 0 12px; color: #666666; font-size: 14px; line-height: 1.6;">
Need help? Check out our <a
href="https://docs.llmgateway.io" style="color: #000000; text-decoration: none;"
>documentation</a> or reply to this email for any questions.
</p>
<p style="margin: 0; color: #999999; font-size: 12px;">
© 2025 LLM Gateway. All rights reserved. This is a transactional email and it can't be unsubscribed from.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`.trim();
}

Copilot AI Jan 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new generatePaymentFailureEmailHtml function lacks test coverage. Since the repository includes comprehensive automated testing for utility functions (as seen in email-validation.spec.ts and invoice.spec.ts), this new email generation function should have corresponding tests to cover various error scenarios including different decline codes, error codes, and edge cases like missing amount/currency values.

Copilot uses AI. Check for mistakes.
>documentation</a> or reply to this email for any questions.
</p>
<p style="margin: 0; color: #999999; font-size: 12px;">
© 2025 LLM Gateway. All rights reserved. This is a transactional email and it can't be unsubscribed from.

Copilot AI Jan 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded year "2025" in the footer copyright notice will become outdated. Consider using dynamic year generation based on the current date to avoid manual updates each year.

Suggested change
© 2025 LLM Gateway. All rights reserved. This is a transactional email and it can't be unsubscribed from.
© ${new Date().getFullYear()} LLM Gateway. All rights reserved. This is a transactional email and it can't be unsubscribed from.

Copilot uses AI. Check for mistakes.
Comment on lines +266 to +269
const formattedAmount =
details.amount && details.currency
? `${details.currency} ${details.amount.toFixed(2)}`
: null;

Copilot AI Jan 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The currency formatting displays the currency code followed by the amount (e.g., "USD 10.00"), but standard formatting conventions typically place currency symbols before amounts (e.g., "$10.00") or use proper locale-specific formatting. Consider using Intl.NumberFormat for proper currency formatting based on the currency code, which will handle both symbol placement and decimal precision correctly.

Copilot uses AI. Check for mistakes.
- Track paymentFailureCount and lastPaymentFailureAt on organizations
- Send payment failure emails based on backoff intervals (1h, 2h, 4h, 8h, 16h, 24h)
- Reset failure count on successful payment
- Prevent auto top-up during backoff period
- Update tests to include new payment failure fields

Co-authored-by: terragon-labs[bot] <terragon-labs[bot]@users.noreply.github.com>
@steebchen steebchen changed the title feat(stripe): add payment failure email feat(stripe): add payment failure email with backoff and tracking Jan 5, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/api/src/stripe.ts (1)

591-736: Use atomic increment for paymentFailureCount to prevent race conditions in concurrent webhook handlers.

If Stripe sends multiple payment_intent.payment_failed webhooks in quick succession, the current read-modify-write pattern can cause race conditions where both handlers read the same previousFailureCount, increment it, and write back the same value, resulting in only +1 instead of +2. Email sending logic could also trigger multiple times when only one should.

Replace the increment with an atomic database operation:

Suggested fix
await db
  .update(tables.organization)
  .set({
    paymentFailureCount: sql`${tables.organization.paymentFailureCount} + 1`,
    lastPaymentFailureAt: new Date(),
  })
  .where(eq(tables.organization.id, organizationId));

This pattern is already used elsewhere in the codebase (e.g., apps/worker/src/worker.ts for credits and referralEarnings increments).

🧹 Nitpick comments (1)
apps/api/src/stripe.ts (1)

713-719: Consider simplifying redundant nullish coalescing.

The errorCode ?? undefined and declineCode ?? undefined conversions are redundant since PaymentFailureDetails accepts optional string fields, and both null and undefined would work. However, this is a minor style preference and doesn't affect functionality.

🔎 Optional simplification
 				html: generatePaymentFailureEmailHtml(organization.name, {
 					errorMessage,
-					errorCode: errorCode ?? undefined,
-					declineCode: declineCode ?? undefined,
+					errorCode,
+					declineCode,
 					amount: totalAmountInDollars,
 					currency: paymentIntent.currency.toUpperCase(),
 				}),
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6296da0 and 41a1146.

📒 Files selected for processing (5)
  • apps/api/src/stripe.ts
  • apps/gateway/src/lib/rate-limit.spec.ts
  • apps/worker/src/worker.ts
  • packages/db/src/schema.ts
  • packages/db/src/types.ts
🧰 Additional context used
📓 Path-based instructions (9)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Never use any or as any unless absolutely necessary in TypeScript code
For database reads: Use db().query.<table>.findMany() or db().query.<table>.findFirst()

Files:

  • packages/db/src/schema.ts
  • apps/worker/src/worker.ts
  • apps/gateway/src/lib/rate-limit.spec.ts
  • packages/db/src/types.ts
  • apps/api/src/stripe.ts
**/*.{ts,tsx,js,jsx,json,md}

📄 CodeRabbit inference engine (CLAUDE.md)

Always use tabs for indentation

Files:

  • packages/db/src/schema.ts
  • apps/worker/src/worker.ts
  • apps/gateway/src/lib/rate-limit.spec.ts
  • packages/db/src/types.ts
  • apps/api/src/stripe.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx,js,jsx}: Always use top-level import, never use require or dynamic imports
No unnecessary code comments

Files:

  • packages/db/src/schema.ts
  • apps/worker/src/worker.ts
  • apps/gateway/src/lib/rate-limit.spec.ts
  • packages/db/src/types.ts
  • apps/api/src/stripe.ts
packages/db/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use Drizzle ORM with latest object syntax for database operations

Files:

  • packages/db/src/schema.ts
  • packages/db/src/types.ts
**/*.{js,ts,tsx,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Always use top-level import, never use require or dynamic imports

Files:

  • packages/db/src/schema.ts
  • apps/worker/src/worker.ts
  • apps/gateway/src/lib/rate-limit.spec.ts
  • packages/db/src/types.ts
  • apps/api/src/stripe.ts
{apps/api,apps/gateway,packages/db}/**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

{apps/api,apps/gateway,packages/db}/**/*.ts: Use Drizzle ORM with latest object syntax for database operations
For database reads: Use db().query.<table>.findMany() or db().query.<table>.findFirst()

Files:

  • packages/db/src/schema.ts
  • apps/gateway/src/lib/rate-limit.spec.ts
  • packages/db/src/types.ts
  • apps/api/src/stripe.ts
**/*.spec.ts

📄 CodeRabbit inference engine (CLAUDE.md)

Unit tests should use *.spec.ts file naming convention

Files:

  • apps/gateway/src/lib/rate-limit.spec.ts
apps/{gateway,api}/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use Hono framework with Zod validation and OpenAPI documentation for backend APIs

Files:

  • apps/gateway/src/lib/rate-limit.spec.ts
  • apps/api/src/stripe.ts
apps/{gateway,api}/src/**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

apps/{gateway,api}/src/**/*.ts: Use Hono for backend framework in Gateway and API services
Use Zod schemas for validation in Hono services

Files:

  • apps/gateway/src/lib/rate-limit.spec.ts
  • apps/api/src/stripe.ts
🧬 Code graph analysis (2)
apps/worker/src/worker.ts (1)
packages/logger/src/index.ts (1)
  • logger (181-181)
apps/api/src/stripe.ts (2)
packages/db/src/schema.ts (1)
  • organization (109-149)
apps/api/src/utils/email.ts (2)
  • sendTransactionalEmail (38-105)
  • generatePaymentFailureEmailHtml (259-385)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (10)
  • GitHub Check: generate / run
  • GitHub Check: lint / run
  • GitHub Check: build / run
  • GitHub Check: test / run
  • GitHub Check: e2e-shards (5)
  • GitHub Check: e2e-shards (4)
  • GitHub Check: e2e-shards (3)
  • GitHub Check: e2e-shards (2)
  • GitHub Check: e2e-shards (1)
  • GitHub Check: autofix
🔇 Additional comments (7)
apps/gateway/src/lib/rate-limit.spec.ts (1)

106-107: LGTM! Test fixtures updated correctly.

The addition of paymentFailureCount and lastPaymentFailureAt fields to all mocked organization objects ensures the test fixtures align with the updated organization schema. The default values (0 and null) are appropriate for rate-limit test scenarios that don't involve payment failure behavior.

Also applies to: 152-153, 195-196, 246-247, 297-298

packages/db/src/schema.ts (1)

147-148: LGTM!

The new columns for payment failure tracking are well-defined. The paymentFailureCount with default 0 and nullable lastPaymentFailureAt timestamp properly support the exponential backoff feature.

packages/db/src/types.ts (1)

106-123: LGTM!

Correctly excludes the new payment failure tracking fields from SerializedOrganization. These are internal operational fields that shouldn't be exposed to clients.

apps/worker/src/worker.ts (1)

149-199: LGTM! Exponential backoff implementation is correct.

The backoff logic correctly:

  • Skips top-up if there's a pending transaction within 1 hour
  • Applies exponential backoff based on failure count (1h, 2h, 4h, 8h, 16h, 24h capped)
  • Guards against null lastPaymentFailureAt before calculating backoff

Minor observation: Line 181 has a redundant ?? 0 since the condition at line 180 already guarantees paymentFailureCount > 0, but this is defensive and doesn't affect correctness.

apps/api/src/stripe.ts (3)

458-466: LGTM! Correctly resets payment failure tracking on success.

Resetting paymentFailureCount to 0 and lastPaymentFailureAt to null when a payment succeeds ensures the backoff state is cleared, allowing normal auto top-up behavior to resume.


617-621: Good extraction of Stripe error details.

Properly extracts message, code, and decline_code from last_payment_error for use in transaction descriptions and email content.


671-704: Exponential backoff logic for emails is correctly implemented.

The implementation:

  • Captures previousFailureCount before incrementing
  • Updates DB with new failure count
  • Uses the previous state to determine if enough time has passed since the last email
  • Backoff intervals align with worker.ts (1h, 2h, 4h, 8h, 16h, 24h capped)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (3)
packages/db/migrations/1767656749_broken_stranger.sql (2)

1-1: Consider conventional DEFAULT/NOT NULL ordering.

The syntax DEFAULT 0 NOT NULL is valid, but PostgreSQL convention typically places NOT NULL DEFAULT 0 (constraint before default). Both work identically, but the conventional ordering improves readability.

🔎 Conventional ordering
-ALTER TABLE "organization" ADD COLUMN "payment_failure_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
+ALTER TABLE "organization" ADD COLUMN "payment_failure_count" integer NOT NULL DEFAULT 0;--> statement-breakpoint

1-2: Consider indexing last_payment_failure_at for backoff queries.

Since the exponential backoff logic (described in PR objectives) likely queries organizations by lastPaymentFailureAt to determine retry windows, an index may improve performance as the organization table grows. Evaluate query patterns and table size to determine if CREATE INDEX idx_organization_last_payment_failure_at ON organization(last_payment_failure_at) would be beneficial.

packages/db/migrations/meta/_journal.json (1)

558-564: File uses spaces instead of tabs for indentation.

The coding guidelines require tabs for indentation in JSON files, but this file uses spaces (2-space indentation). The new lines correctly maintain consistency with the existing file format, but the entire file violates the guideline. Consider reformatting the entire file to use tabs in a follow-up PR to ensure compliance.

As per coding guidelines: "Always use tabs for indentation" for *.json files.

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 41a1146 and 60e001b.

📒 Files selected for processing (3)
  • packages/db/migrations/1767656749_broken_stranger.sql
  • packages/db/migrations/meta/1767656749_snapshot.json
  • packages/db/migrations/meta/_journal.json
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx,js,jsx,json,md}

📄 CodeRabbit inference engine (CLAUDE.md)

Always use tabs for indentation

Files:

  • packages/db/migrations/meta/_journal.json
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (10)
  • GitHub Check: e2e-shards (2)
  • GitHub Check: e2e-shards (1)
  • GitHub Check: e2e-shards (4)
  • GitHub Check: e2e-shards (5)
  • GitHub Check: e2e-shards (3)
  • GitHub Check: lint / run
  • GitHub Check: test / run
  • GitHub Check: build / run
  • GitHub Check: generate / run
  • GitHub Check: autofix
🔇 Additional comments (1)
packages/db/migrations/meta/_journal.json (1)

558-564: Migration entry structure is correct.

The new migration entry follows the established pattern with the correct sequence (idx 79), version ("8"), timestamp matching the filename, and appropriate tag.

steebchen and others added 2 commits January 13, 2026 20:30
- Added detailed warning logs for payment intent failures including error codes and payment details.
- Changed info logs to warning logs to better reflect severity for email sending and backoff situations.
- Enhanced payment failure email HTML generation by escaping currency and handling zero amount cases properly to avoid displaying incorrect values.

Co-authored-by: terragon-labs[bot] <terragon-labs[bot]@users.noreply.github.com>
@steebchen steebchen changed the title feat(stripe): add payment failure email with backoff and tracking feat(stripe): handle payment failures with email, tracking and backoff Jan 14, 2026
@steebchen
steebchen added this pull request to the merge queue Jan 14, 2026
Merged via the queue into main with commit 8a0ca49 Jan 14, 2026
14 of 15 checks passed
@steebchen
steebchen deleted the terragon/handle-stripe-card-error-email-w2n6gu branch January 14, 2026 17:05
rcogal pushed a commit that referenced this pull request Jan 15, 2026
#1407)

## Summary
- Sends payment failure emails to an organization when a Stripe payment
fails, with detailed error information and actionable guidance
- Introduces exponential backoff and tracking for payment failures
(paymentFailureCount, lastPaymentFailureAt) to avoid spamming on
repeated failures
- Adds new HTML email template generated by
generatePaymentFailureEmailHtml, including error details, amount, and
tailored action messaging
- Updates logs and transaction/organization state to reflect failure
context and backoff status
- Resets failure metrics on successful payments

## Changes

### Backend
- apps/api/src/stripe.ts
  - Extract last_payment_error details: message, code, decline_code
  - Compute totalAmountInDollars for the failed charge
- Send email to organization.billingEmail with subject "Payment Failed -
Action Required"
- Email HTML generated by
generatePaymentFailureEmailHtml(organization.name, details)
- Logs success/failure of sent emails and updates transaction
descriptions with the errorMessage
- Updates payment failure tracking on each failure: paymentFailureCount
and lastPaymentFailureAt, enabling exponential backoff
- Implements exponential backoff logic to determine whether to send the
email immediately or skip until the next window
- Resets paymentFailureCount and lastPaymentFailureAt when a payment
eventually succeeds (to start fresh)
- On email delivery, logs success; on failure, logs error without
crashing

### Email Utilities
- apps/api/src/utils/email.ts
  - Added PaymentFailureDetails interface
- Implemented generatePaymentFailureEmailHtml(organizationName, details)
  - Escapes HTML inputs and formats amount with currency when provided
- Determines user-facing actionMessage based on declineCode and
errorCode (e.g., insufficient_funds, expired_card,
lost_card/stolen_card)
- Returns a complete, styled HTML email including error details, amount,
currency, and a CTA to update payment method

### Worker
- apps/worker/src/worker.ts
- Adjusted auto top-up flow to respect backoff: checks
lastPaymentFailureAt and paymentFailureCount to skip retries during
backoff windows
- Keeps existing pending transaction checks but adds a backoff gate so
we don’t retry too aggressively when failures occur

### Database
- Updated schema to support backoff tracking
- packages/db/src/schema.ts: added paymentFailureCount: integer not null
default 0 and lastPaymentFailureAt: timestamp()
- Updated types to reflect new fields
- packages/db/src/types.ts: SerializedOrganization now excludes
paymentFailureCount and lastPaymentFailureAt from certain
representations but those fields exist on the DB level

### Validation / Testing
- Trigger a Stripe payment failure and verify:
- The associated transaction is updated to failed with the correct
errorMessage
- An email is sent to organization.billingEmail with subject "Payment
Failed - Action Required"
  - Email content includes:
    - Error message
    - Amount (currency and value) when available
    - Action guidance tailored to decline_code/error_code
  - Logs include the errorMessage and confirmation of email delivery
- Validate exponential backoff behavior by simulating multiple
consecutive failures:
  - First failure should send email immediately
- Subsequent failures should respect backoff windows (e.g., 1h, 2h, 4h,
etc.) and skip email during backoff periods
- Manually test different decline codes (insufficient_funds,
expired_card, lost_card/stolen_card) to ensure the actionMessage changes
accordingly
- Verify worker backoff logic prevents new top-ups during backoff
periods for the same organization

### Notes
- Migrations added to support backoff tracking:
- packages/db/src/schema.ts updated to add paymentFailureCount and
lastPaymentFailureAt
- packages/db/migrations/1767656749_broken_stranger.sql added to migrate
existing data
- packages/db/migrations/meta/1767656749_snapshot.json and _journal.json
updated to reflect migration
- DB schema now includes backoff-tracking fields (paymentFailureCount
and lastPaymentFailureAt)
- Task:
https://www.terragonlabs.com/task/45ea62cf-fd15-4f22-b95c-54158f13257c

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Payment failure emails now include detailed error info (message,
codes, decline reason, amount) and use a polished HTML layout.
* Automatic top-up retries now follow an exponential backoff schedule
(1h, 2h, 4h, 8h, 16h, 24h cap).

* **Bug Fixes**
* Successful payments reset failure tracking so normal operations
resume.

* **Tests / Chores**
* Tests and database schema updated to track failure count and last
failure timestamp for organizations.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: terragon-labs[bot] <terragon-labs[bot]@users.noreply.github.com>
@coderabbitai coderabbitai Bot mentioned this pull request Apr 13, 2026
12 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants