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
19 changes: 19 additions & 0 deletions apps/api/src/lib/stripe-card-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Stripe raises StripeCardError when a charge is rejected by the card or its
// issuer (declined, incorrect CVC, expired card, insufficient funds, ...).
// Its `message` is Stripe's own user-facing explanation of what went wrong
// ("Your card's security code is incorrect."), so it is safe — and far more
// actionable than a generic "payment failed" — to surface verbatim to the
// client. Duck-typed on `type` rather than `instanceof` so it also matches
// error-shaped objects from mocks and re-serialized errors.
export function getStripeCardErrorMessage(error: unknown): string | undefined {
if (typeof error !== "object" || error === null) {
return undefined;
}
const stripeErr = error as { type?: unknown; message?: unknown };
if (stripeErr.type !== "StripeCardError") {
return undefined;
}
return typeof stripeErr.message === "string" && stripeErr.message
? stripeErr.message
: "Your card was declined.";
}
13 changes: 13 additions & 0 deletions apps/api/src/routes/chat-plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { HTTPException } from "hono/http-exception";
import { z } from "zod";

import { voidPendingCycleRenewalInvoices } from "@/lib/pending-renewal.js";
import { getStripeCardErrorMessage } from "@/lib/stripe-card-error.js";
import { ensureStripeCustomer } from "@/stripe.js";
import { getOrCreateChatOrg } from "@/utils/personal-org.js";

Expand Down Expand Up @@ -545,6 +546,18 @@ chatPlans.openapi(changeTier, async (c) => {
typeof error === "object" && error !== null && "code" in error
? String((error as { code?: unknown }).code)
: undefined;
const cardErrorMessage = getStripeCardErrorMessage(error);
if (cardErrorMessage) {
// Any card error (incorrect CVC, expired card, insufficient funds, plain
// decline, ...) carries Stripe's user-facing explanation — pass it
// through so the user learns what to fix instead of a generic failure.
logger.warn("Chat plan tier change payment declined", {
code: errCode,
});
throw new HTTPException(402, {
message: `${cardErrorMessage} Update your payment method and try again.`,
});
}
if (errCode === "card_declined" || errCode === "invoice_payment_required") {
logger.warn("Chat plan tier change payment declined", {
code: errCode,
Expand Down
51 changes: 51 additions & 0 deletions apps/api/src/routes/dev-plans.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,57 @@ describe("dev plan tier changes", () => {
expect(transaction).toBeUndefined();
});

it("surfaces Stripe's card error message as a 402, not a generic 500", async () => {
stripeMock.subscriptions.retrieve.mockResolvedValue(
retrievedSubscription(),
);
stripeMock.prices.retrieve.mockResolvedValue({ unit_amount: 7900 });
// `error_if_incomplete` rejects the update when the card itself is
// refused (wrong CVC here, but equally expired card, insufficient funds,
// a plain decline, ...) and leaves the subscription on the old tier. The
// error's message is Stripe's user-facing explanation and must reach the
// client verbatim so the user knows what to fix.
stripeMock.subscriptions.update.mockRejectedValue({
type: "StripeCardError",
rawType: "card_error",
code: "incorrect_cvc",
message: "Your card's security code is incorrect.",
});

const res = await app.request("/dev-plans/change-tier", {
method: "POST",
headers: {
Cookie: token,
"Content-Type": "application/json",
},
body: JSON.stringify({
newTier: "pro",
expectedAmountDueCents: 7900,
}),
});

expect(res.status).toBe(402);
const body = await res.json();
expect(body.message).toBe(
"Your card's security code is incorrect. Update your payment method and try again.",
);

// Nothing was applied locally and the lease is released, so the user can
// fix the card and retry immediately.
const org = await db.query.organization.findFirst({
where: { id: { eq: ORG_ID } },
});
expect(org?.devPlan).toBe("lite");
expect(org?.devPlanCreditsUsed).toBe("12.5");
expect(org?.devPlanCreditsLimit).toBe("87");
expect(org?.devPlanTierChangeClaimedAt).toBeNull();

const transaction = await db.query.transaction.findFirst({
where: { organizationId: { eq: ORG_ID } },
});
expect(transaction).toBeUndefined();
});

it("voids a pending cycle-renewal invoice before re-anchoring the cycle", async () => {
// The old cycle just ended: Stripe drafted its renewal invoice but has
// not charged it yet (that happens ~1h after drafting). The upgrade must
Expand Down
22 changes: 16 additions & 6 deletions apps/api/src/routes/dev-plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
isSelfRefundCandidateType,
refundFeedbackBodySchema,
} from "@/lib/self-refund.js";
import { getStripeCardErrorMessage } from "@/lib/stripe-card-error.js";
import { posthog } from "@/posthog.js";
import {
ensureStripeCustomer,
Expand Down Expand Up @@ -1507,6 +1508,18 @@ devPlans.openapi(changeTier, async (c) => {
// user-facing outcome, not a server fault, so log it at warn — never
// error — to avoid noisy alerts for declined cards.
const errCode = getStripeErrorCode(error);
const cardErrorMessage = getStripeCardErrorMessage(error);
if (cardErrorMessage) {
// Any card error (incorrect CVC, expired card, insufficient funds, plain
// decline, ...) carries Stripe's user-facing explanation — pass it
// through so the user learns what to fix instead of a generic failure.
logger.warn("Dev plan tier change payment declined", {
code: errCode,
});
throw new HTTPException(402, {
message: `${cardErrorMessage} Update your payment method and try again.`,
});
}
if (errCode === "card_declined" || errCode === "invoice_payment_required") {
logger.warn("Dev plan tier change payment declined", {
code: errCode,
Expand Down Expand Up @@ -3066,14 +3079,11 @@ devPlans.openapi(purchaseResetPass, async (c) => {
// tier-change handler. Anything else (configuration, outage,
// programming errors) is rethrown to the global error handler.
const stripeErr = err as { type?: string; code?: string };
if (
stripeErr?.type === "StripeCardError" ||
stripeErr?.code === "card_declined"
) {
const cardErrorMessage = getStripeCardErrorMessage(err);
if (cardErrorMessage || stripeErr?.code === "card_declined") {
logger.warn("Reset Pass charge declined", { code: stripeErr.code });
throw new HTTPException(402, {
message:
"Your card was declined. Update your payment method on the billing page and try again.",
message: `${cardErrorMessage ?? "Your card was declined."} Update your payment method on the billing page and try again.`,
});
}
throw err;
Expand Down
Loading