Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 91 additions & 7 deletions apps/api/src/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { logger } from "@llmgateway/logger";
import { posthog } from "./posthog.js";
import { stripe } from "./routes/payments.js";
import {
generatePaymentFailureEmailHtml,
generateSubscriptionCancelledEmailHtml,
generateTrialStartedEmailHtml,
sendTransactionalEmail,
Expand Down Expand Up @@ -454,10 +455,13 @@ async function handlePaymentIntentSucceeded(
}

// Update organization credits with credit amount (plus bonus if applicable)
// Also reset payment failure tracking since payment succeeded
await db
.update(tables.organization)
.set({
credits: sql`${tables.organization.credits} + ${finalCreditAmount}`,
paymentFailureCount: 0,
lastPaymentFailureAt: null,
})
.where(eq(tables.organization.id, organizationId));

Expand Down Expand Up @@ -600,7 +604,7 @@ async function handlePaymentIntentFailed(
return;
}

const { organizationId } = result;
const { organizationId, organization } = result;

// Convert amount from cents to dollars
const totalAmountInDollars = amount / 100;
Expand All @@ -610,6 +614,24 @@ async function handlePaymentIntentFailed(
? parseFloat(metadata.baseAmount)
: null;

// Extract error details from Stripe
const lastPaymentError = paymentIntent.last_payment_error;
const errorMessage = lastPaymentError?.message || "Unknown error";
const errorCode = lastPaymentError?.code;
const declineCode = lastPaymentError?.decline_code;

// Log warning for payment failure
logger.warn("Payment intent failed", {
organizationId,
organizationName: organization.name,
amount: totalAmountInDollars,
currency: paymentIntent.currency.toUpperCase(),
errorMessage,
errorCode,
declineCode,
stripePaymentIntentId: paymentIntent.id,
});

// Check if this is an auto top-up with an existing pending transaction
const transactionId = metadata?.transactionId;
if (transactionId) {
Expand All @@ -618,7 +640,7 @@ async function handlePaymentIntentFailed(
.update(tables.transaction)
.set({
status: "failed",
description: `Auto top-up failed via Stripe webhook: ${paymentIntent.last_payment_error?.message || "Unknown error"}`,
description: `Auto top-up failed via Stripe webhook: ${errorMessage}`,
})
.where(eq(tables.transaction.id, transactionId))
.returning()
Expand All @@ -641,7 +663,7 @@ async function handlePaymentIntentFailed(
currency: paymentIntent.currency.toUpperCase(),
status: "failed",
stripePaymentIntentId: paymentIntent.id,
description: `Credit top-up failed via Stripe (fallback): ${paymentIntent.last_payment_error?.message || "Unknown error"}`,
description: `Credit top-up failed via Stripe (fallback): ${errorMessage}`,
});
}
} else {
Expand All @@ -654,13 +676,75 @@ async function handlePaymentIntentFailed(
currency: paymentIntent.currency.toUpperCase(),
status: "failed",
stripePaymentIntentId: paymentIntent.id,
description: `Credit top-up failed via Stripe: ${paymentIntent.last_payment_error?.message || "Unknown error"}`,
description: `Credit top-up failed via Stripe: ${errorMessage}`,
});
}

logger.info(
`Payment intent failed for organization ${organizationId}: ${paymentIntent.last_payment_error?.message || "Unknown error"}`,
);
// Update payment failure tracking with exponential backoff
// Calculate new failure count and check if we should send an email
const previousFailureCount = organization.paymentFailureCount ?? 0;
const previousFailureAt = organization.lastPaymentFailureAt;
const newFailureCount = previousFailureCount + 1;

// Update organization with new failure count and timestamp
await db
.update(tables.organization)
.set({
paymentFailureCount: newFailureCount,
lastPaymentFailureAt: new Date(),
})
.where(eq(tables.organization.id, organizationId));

// Determine if we should send an email based on exponential backoff
// Email intervals: 1st failure immediately, then 1h, 2h, 4h, 8h, 16h, 24h (capped)
let shouldSendEmail = false;
if (previousFailureCount === 0) {
// First failure - always send email
shouldSendEmail = true;
} else if (previousFailureAt) {
// Calculate backoff period based on previous failure count
const baseBackoffHours = 1;
const maxBackoffHours = 24;
const backoffHours = Math.min(
baseBackoffHours * Math.pow(2, previousFailureCount - 1),
maxBackoffHours,
);
const backoffMs = backoffHours * 60 * 60 * 1000;
const nextEmailTime = new Date(previousFailureAt.getTime() + backoffMs);

// Send email if we're past the backoff period
shouldSendEmail = new Date() >= nextEmailTime;
}

// Send payment failure email if not in backoff period
if (shouldSendEmail) {
try {
await sendTransactionalEmail({
to: organization.billingEmail,
subject: "Payment Failed - Action Required",
html: generatePaymentFailureEmailHtml(organization.name, {
errorMessage,
errorCode,
declineCode,
amount: totalAmountInDollars,
currency: paymentIntent.currency.toUpperCase(),
}),
});

logger.warn("Payment failure email sent", {
organizationId,
billingEmail: organization.billingEmail,
failureCount: newFailureCount,
});
} catch (emailError) {
logger.error("Failed to send payment failure email", emailError as Error);
}
} else {
logger.warn("Skipping payment failure email (in backoff period)", {
organizationId,
failureCount: newFailureCount,
});
}
}

async function handleChargeRefunded(event: Stripe.ChargeRefundedEvent) {
Expand Down
140 changes: 140 additions & 0 deletions apps/api/src/utils/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,146 @@ export function generateTrialStartedEmailHtml(
`.trim();
}

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);

// Escape currency and handle zero amount case properly
const escapedCurrency = details.currency
? escapeHtml(details.currency)
: null;
const formattedAmount =
details.amount !== undefined && details.amount !== null && escapedCurrency
? `${escapedCurrency} ${details.amount.toFixed(2)}`
: null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +270 to +273

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.

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>` : ""}

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.
</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.

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.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`.trim();
}
Comment on lines +251 to +389

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.

export function generateSubscriptionCancelledEmailHtml(
organizationName: string,
): string {
Expand Down
10 changes: 10 additions & 0 deletions apps/gateway/src/lib/rate-limit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ describe("Rate Limiting", () => {
retentionLevel: "retain" as const,
status: "active" as const,
referralEarnings: "0",
paymentFailureCount: 0,
lastPaymentFailureAt: null,
});

vi.mocked(redis.zcard).mockResolvedValue(0);
Expand Down Expand Up @@ -147,6 +149,8 @@ describe("Rate Limiting", () => {
retentionLevel: "retain" as const,
status: "active" as const,
referralEarnings: "0",
paymentFailureCount: 0,
lastPaymentFailureAt: null,
});

vi.mocked(redis.zcard).mockResolvedValue(5); // Under elevated limit (20)
Expand Down Expand Up @@ -188,6 +192,8 @@ describe("Rate Limiting", () => {
retentionLevel: "retain" as const,
status: "active" as const,
referralEarnings: "0",
paymentFailureCount: 0,
lastPaymentFailureAt: null,
});

vi.mocked(redis.zcard).mockResolvedValue(5); // At limit (5)
Expand Down Expand Up @@ -237,6 +243,8 @@ describe("Rate Limiting", () => {
retentionLevel: "retain" as const,
status: "active" as const,
referralEarnings: "0",
paymentFailureCount: 0,
lastPaymentFailureAt: null,
});

vi.mocked(redis.zcard).mockResolvedValue(20); // At elevated limit (20)
Expand Down Expand Up @@ -286,6 +294,8 @@ describe("Rate Limiting", () => {
retentionLevel: "retain" as const,
status: "active" as const,
referralEarnings: "0",
paymentFailureCount: 0,
lastPaymentFailureAt: null,
});
vi.mocked(redis.zremrangebyscore).mockRejectedValue(
new Error("Redis error"),
Expand Down
Loading
Loading