feat(stripe): handle payment failures with email, tracking and backoff - #1407
Conversation
…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>
|
Note Other AI code review bot(s) detectedCodeRabbit 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. WalkthroughAdds 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
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
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested labels
Suggested reviewers
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
📜 Recent review detailsConfiguration used: Repository UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
⏰ 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)
✏️ Tip: You can disable this entire section by setting 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. Comment |
There was a problem hiding this comment.
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
actionMessagevariable 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
📒 Files selected for processing (2)
apps/api/src/stripe.tsapps/api/src/utils/email.ts
🧰 Additional context used
📓 Path-based instructions (7)
**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
**/*.{ts,tsx}: Never useanyoras anyunless absolutely necessary in TypeScript code
For database reads: Usedb().query.<table>.findMany()ordb().query.<table>.findFirst()
Files:
apps/api/src/utils/email.tsapps/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.tsapps/api/src/stripe.ts
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (CLAUDE.md)
**/*.{ts,tsx,js,jsx}: Always use top-levelimport, never use require or dynamic imports
No unnecessary code comments
Files:
apps/api/src/utils/email.tsapps/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.tsapps/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.tsapps/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: Usedb().query.<table>.findMany()ordb().query.<table>.findFirst()
Files:
apps/api/src/utils/email.tsapps/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.tsapps/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
errorMessagein 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. Theorganization.billingEmailis 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.
There was a problem hiding this comment.
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.billingEmailwhen payment intents fail - Extracts and standardizes error information from Stripe's
last_payment_errorfor 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.
| errorCode: errorCode ?? undefined, | ||
| declineCode: declineCode ?? undefined, |
There was a problem hiding this comment.
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.
| errorCode: errorCode ?? undefined, | |
| declineCode: declineCode ?? undefined, | |
| errorCode, | |
| declineCode, |
| <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>` : ""} |
There was a problem hiding this comment.
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.
| ${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>` : ""} |
| const escapedErrorMessage = escapeHtml(details.errorMessage); | ||
|
|
||
| const formattedAmount = | ||
| details.amount && details.currency |
There was a problem hiding this comment.
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.
| details.amount && details.currency | |
| details.amount !== undefined && | |
| details.amount !== null && | |
| details.currency |
| 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(); | ||
| } |
There was a problem hiding this comment.
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.
| >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. |
There was a problem hiding this comment.
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.
| © 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. |
| const formattedAmount = | ||
| details.amount && details.currency | ||
| ? `${details.currency} ${details.amount.toFixed(2)}` | ||
| : null; |
There was a problem hiding this comment.
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.
- 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>
There was a problem hiding this comment.
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 forpaymentFailureCountto prevent race conditions in concurrent webhook handlers.If Stripe sends multiple
payment_intent.payment_failedwebhooks in quick succession, the current read-modify-write pattern can cause race conditions where both handlers read the samepreviousFailureCount, 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.tsforcreditsandreferralEarningsincrements).
🧹 Nitpick comments (1)
apps/api/src/stripe.ts (1)
713-719: Consider simplifying redundant nullish coalescing.The
errorCode ?? undefinedanddeclineCode ?? undefinedconversions are redundant sincePaymentFailureDetailsaccepts optional string fields, and bothnullandundefinedwould 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
📒 Files selected for processing (5)
apps/api/src/stripe.tsapps/gateway/src/lib/rate-limit.spec.tsapps/worker/src/worker.tspackages/db/src/schema.tspackages/db/src/types.ts
🧰 Additional context used
📓 Path-based instructions (9)
**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
**/*.{ts,tsx}: Never useanyoras anyunless absolutely necessary in TypeScript code
For database reads: Usedb().query.<table>.findMany()ordb().query.<table>.findFirst()
Files:
packages/db/src/schema.tsapps/worker/src/worker.tsapps/gateway/src/lib/rate-limit.spec.tspackages/db/src/types.tsapps/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.tsapps/worker/src/worker.tsapps/gateway/src/lib/rate-limit.spec.tspackages/db/src/types.tsapps/api/src/stripe.ts
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (CLAUDE.md)
**/*.{ts,tsx,js,jsx}: Always use top-levelimport, never use require or dynamic imports
No unnecessary code comments
Files:
packages/db/src/schema.tsapps/worker/src/worker.tsapps/gateway/src/lib/rate-limit.spec.tspackages/db/src/types.tsapps/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.tspackages/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.tsapps/worker/src/worker.tsapps/gateway/src/lib/rate-limit.spec.tspackages/db/src/types.tsapps/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: Usedb().query.<table>.findMany()ordb().query.<table>.findFirst()
Files:
packages/db/src/schema.tsapps/gateway/src/lib/rate-limit.spec.tspackages/db/src/types.tsapps/api/src/stripe.ts
**/*.spec.ts
📄 CodeRabbit inference engine (CLAUDE.md)
Unit tests should use
*.spec.tsfile 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.tsapps/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.tsapps/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
paymentFailureCountandlastPaymentFailureAtfields 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
paymentFailureCountwith default0and nullablelastPaymentFailureAttimestamp 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
lastPaymentFailureAtbefore calculating backoffMinor observation: Line 181 has a redundant
?? 0since the condition at line 180 already guaranteespaymentFailureCount > 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
paymentFailureCountto 0 andlastPaymentFailureAtto 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, anddecline_codefromlast_payment_errorfor use in transaction descriptions and email content.
671-704: Exponential backoff logic for emails is correctly implemented.The implementation:
- Captures
previousFailureCountbefore 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)
There was a problem hiding this comment.
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 NULLis valid, but PostgreSQL convention typically placesNOT 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 indexinglast_payment_failure_atfor backoff queries.Since the exponential backoff logic (described in PR objectives) likely queries organizations by
lastPaymentFailureAtto determine retry windows, an index may improve performance as the organization table grows. Evaluate query patterns and table size to determine ifCREATE 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
*.jsonfiles.
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
packages/db/migrations/1767656749_broken_stranger.sqlpackages/db/migrations/meta/1767656749_snapshot.jsonpackages/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.
…e-card-error-email-w2n6gu
- 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>
#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>
Summary
Changes
Backend
Email Utilities
Worker
Database
Validation / Testing
Notes
Summary by CodeRabbit
New Features
Bug Fixes
Tests / Chores
✏️ Tip: You can customize this high-level summary in your review settings.