feat(platform): pay the open trial invoice from the payment failed state

When a trial's first charge fails, checkout refuses a new plan while the
trial subscription is still open (ensure_no_unconverted_trial), so the
Start Pro buttons on billing and in the paywall ended in an error, and
billing repeated the failure in four places with three buttons.

The trial status now carries payment_due, read from the trial
subscription's latest invoice when it is open and owned by the trial's
customer and enrollment. Billing, the limit popup, the in-chat notice and
the paywall show one message with the amount and a "Pay $X now" link to
Stripe's hosted invoice page, which takes any enabled payment method and
handles 3D Secure. Paying it converts the trial through the existing
invoice-paid path. While the card is shown the trial status is rechecked
every 15 seconds in a visible tab, and plan and usage refresh once the
conversion lands. Without a readable invoice, billing points to the
payment method row and the popup keeps "Review billing details".

The amount owed is masked in session replays wherever it shows, like the
other account-specific charges in this PR.

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
Claude-Session: https://claude.ai/code/session_01StgD1TkCx1ku4bjHh7t4VZ
This commit is contained in:
Claude committed 2026-10-14 11:57:08 -07:00
1 parent abcb6afd83
commit e900125ced
30 files changed
+958 -139

No files matched your search

@@ -4,6 +4,7 @@ from typing import Literal
from pydantic import BaseModel, Field
from backend.data.subscription_trial_config import AcceptedTrialOffer
from backend.data.subscription_trial_invoice import TrialPaymentDue
from backend.data.subscription_trial_rejection import TrialRejectionReason
@@ -34,6 +35,7 @@ class TrialStatusResponse(BaseModel):
active: bool = False
converted: bool = False
onboarding_credits_previously_received: bool = False
payment_due: TrialPaymentDue | None = None
class TrialCheckoutRequest(BaseModel):
@@ -39,6 +39,7 @@ from backend.data.subscription_trial_checkout import (
resolve_trial_price,
)
from backend.data.subscription_trial_config import get_trial_offer
from backend.data.subscription_trial_invoice import get_trial_payment_due
from backend.data.user import get_user_by_id
from backend.util.product_analytics import track_checkout_started
from backend.util.settings import Settings
@@ -86,6 +87,7 @@ async def get_trial_status(
and trial.offer.weekly_cost_limit >= trial.offer.total_cost_limit
else "rolling"
),
payment_due=await get_trial_payment_due(trial),
)
offer = await get_trial_offer(user_id, country=country)
if offer is None or not await trial_seat_available(offer):
@@ -14,6 +14,7 @@ from jwt.algorithms import ECAlgorithm
from backend.api.features import subscription_trial_routes as routes
from backend.data.subscription_trial import TrialState
from backend.data.subscription_trial_config import AcceptedTrialOffer
from backend.data.subscription_trial_invoice import TrialPaymentDue
@pytest.fixture(autouse=True)
@@ -155,6 +156,41 @@ async def test_status_exposes_safe_rejection_reason(trial):
assert status.model_dump().get("rejection_reason") == "intro_offer_already_used"
@pytest.mark.asyncio
async def test_past_due_status_carries_the_open_invoice(trial):
trial = trial.model_copy(update={"status": "past_due", "subscription_id": "sub_1"})
due = TrialPaymentDue(
amount_due=5000,
currency="usd",
payment_url="https://invoice.stripe.com/i/acct_1/test_inv_1",
)
with (
patch.object(routes, "get_subscription_trial", AsyncMock(return_value=trial)),
patch.object(
routes, "has_received_onboarding_credit", AsyncMock(return_value=False)
),
patch.object(
routes, "get_trial_payment_due", AsyncMock(return_value=due)
) as lookup,
):
status = await routes.get_trial_status(trial.user_id)
assert status.payment_due == due
lookup.assert_awaited_once_with(trial)
@pytest.mark.asyncio
async def test_status_without_a_failed_charge_has_no_payment_due(trial):
with (
patch.object(routes, "get_subscription_trial", AsyncMock(return_value=trial)),
patch.object(routes, "get_trial_offer", AsyncMock(return_value=None)),
patch.object(
routes, "has_received_onboarding_credit", AsyncMock(return_value=False)
),
):
status = await routes.get_trial_status(trial.user_id)
assert status.payment_due is None
@pytest.mark.asyncio
async def test_checkout_uses_authenticated_identity_and_server_return_urls(trial):
app = FastAPI()
@@ -0,0 +1,63 @@
"""The open invoice a trial's failed first charge leaves behind."""
import logging
import stripe
from pydantic import BaseModel, ValidationError
from backend.data.stripe_client import stripe_call
from backend.data.subscription_trial import TrialState
from backend.data.subscription_trial_payment import SubscriptionSnapshot
logger = logging.getLogger(__name__)
PAYMENT_DUE_STATUSES = frozenset({"past_due", "unpaid"})
class TrialPaymentDue(BaseModel):
amount_due: int
currency: str
payment_url: str
async def get_trial_payment_due(trial: TrialState) -> TrialPaymentDue | None:
"""Paying this invoice on Stripe's hosted page converts the trial through
the usual invoice-paid path. Checkout refuses a second plan while it is
open, so it is the one way back to the plan besides Stripe's own retries.
"""
if (
trial.converted_at is not None
or trial.subscription_id is None
or trial.status not in PAYMENT_DUE_STATUSES
):
return None
try:
snapshot = SubscriptionSnapshot.model_validate(
await stripe_call(
stripe.Subscription.retrieve_async,
trial.subscription_id,
expand=["latest_invoice"],
)
)
except (stripe.StripeError, ValidationError):
logger.warning(
f"Could not read the open invoice for trial {trial.id}", exc_info=True
)
return None
invoice = snapshot.latest_invoice
if (
snapshot.customer != trial.customer_id
or snapshot.metadata.get("trial_enrollment_id") != trial.id
or invoice is None
or invoice.status != "open"
or invoice.customer != trial.customer_id
or invoice.amount_due <= 0
or not invoice.currency
or not invoice.hosted_invoice_url
):
return None
return TrialPaymentDue(
amount_due=invoice.amount_due,
currency=invoice.currency,
payment_url=invoice.hosted_invoice_url,
)
@@ -0,0 +1,131 @@
from unittest.mock import AsyncMock, patch
import pytest
import stripe
from backend.data.subscription_trial import TrialState
from backend.data.subscription_trial_invoice import (
TrialPaymentDue,
get_trial_payment_due,
)
pytest_plugins = ("backend.data.subscription_trial_fixtures",)
PAYMENT_URL = "https://invoice.stripe.com/i/acct_1/test_inv_1"
@pytest.fixture
def past_due(trial: TrialState) -> TrialState:
return trial.model_copy(update={"status": "past_due", "subscription_id": "sub_1"})
@pytest.fixture
def subscription(past_due: TrialState) -> dict:
return {
"id": "sub_1",
"customer": past_due.customer_id,
"status": "past_due",
"metadata": {"user_id": past_due.user_id, "trial_enrollment_id": past_due.id},
"latest_invoice": {
"id": "in_1",
"created": 1_760_270_400,
"customer": past_due.customer_id,
"status": "open",
"amount_due": 5413,
"currency": "usd",
"hosted_invoice_url": PAYMENT_URL,
},
}
@pytest.fixture
def retrieve(subscription: dict):
with patch.object(
stripe.Subscription, "retrieve_async", AsyncMock(return_value=subscription)
) as lookup:
yield lookup
@pytest.mark.asyncio
async def test_past_due_trial_gets_its_open_invoice(past_due, retrieve):
due = await get_trial_payment_due(past_due)
assert due == TrialPaymentDue(
amount_due=5413, currency="usd", payment_url=PAYMENT_URL
)
retrieve.assert_awaited_once_with("sub_1", expand=["latest_invoice"])
@pytest.mark.asyncio
async def test_unpaid_trial_gets_its_open_invoice(past_due, retrieve):
due = await get_trial_payment_due(past_due.model_copy(update={"status": "unpaid"}))
assert due is not None and due.payment_url == PAYMENT_URL
@pytest.mark.asyncio
@pytest.mark.parametrize(
"update",
[
{"status": "trialing"},
{"status": "active"},
{"status": "canceled"},
{"subscription_id": None},
],
)
async def test_no_lookup_without_a_failed_conversion(past_due, retrieve, update):
assert await get_trial_payment_due(past_due.model_copy(update=update)) is None
retrieve.assert_not_awaited()
@pytest.mark.asyncio
async def test_no_lookup_after_conversion(past_due, retrieve):
converted = past_due.model_copy(
update={"converted_at": past_due.offer.new_users_from}
)
assert await get_trial_payment_due(converted) is None
retrieve.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"invoice_update",
[
{"status": "paid"},
{"status": "void"},
{"status": "draft"},
{"status": "uncollectible"},
{"customer": "cus_other"},
{"amount_due": 0},
{"hosted_invoice_url": None},
],
)
async def test_only_an_open_invoice_owned_by_the_trial_customer(
past_due, subscription, retrieve, invoice_update
):
subscription["latest_invoice"].update(invoice_update)
assert await get_trial_payment_due(past_due) is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
"subscription_update",
[
{"customer": "cus_other"},
{"metadata": {"trial_enrollment_id": "trial-other"}},
{"latest_invoice": None},
],
)
async def test_only_the_trial_enrollment_subscription(
past_due, subscription, retrieve, subscription_update
):
subscription.update(subscription_update)
assert await get_trial_payment_due(past_due) is None
@pytest.mark.asyncio
async def test_stripe_error_leaves_the_status_readable(past_due):
with patch.object(
stripe.Subscription,
"retrieve_async",
AsyncMock(side_effect=stripe.APIConnectionError("timeout")),
):
assert await get_trial_payment_due(past_due) is None
@@ -81,6 +81,9 @@ class Invoice(BaseModel):
id: str
status: str | None = None
created: int
customer: str | None = None
amount_due: int = 0
currency: str | None = None
billing_reason: str | None = None
hosted_invoice_url: str | None = None
@@ -6,6 +6,8 @@ import { Skeleton } from "@/components/atoms/Skeleton/Skeleton";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { SubscriptionPlans } from "@/components/organisms/SubscriptionPlans/SubscriptionPlans";
import { TrialCardContent } from "@/components/organisms/TrialCard/TrialCard";
import { PaymentRecoveryCard } from "@/components/organisms/UsageExperience/PaymentRecoveryCard";
import { usePaymentRecovery } from "@/services/usageExperience/usePaymentRecovery";
import { Logout03Icon } from "@hugeicons/core-free-icons";
import { SwitchTierDialog } from "../settings/billing/components/SubscriptionTab/YourPlanCard/SwitchTierDialog";
import { PaywallPlanChoices } from "./components/PaywallPlanChoices";
@@ -33,12 +35,16 @@ export function PaywallModal() {
trial,
trialOffer,
} = usePaywallModal();
// The gate only opens this modal for accounts without a plan.
const recovery = usePaymentRecovery("NO_TIER");
return (
<Dialog
variant="compact"
title="Choose your plan"
styling={{ maxWidth: trialOffer ? "1120px" : "700px" }}
title={recovery.paymentFailed ? "Your plan" : "Choose your plan"}
styling={{
maxWidth: trialOffer && !recovery.paymentFailed ? "1120px" : "700px",
}}
controlled={{
isOpen: true,
set: (open) => {
@@ -59,69 +65,81 @@ export function PaywallModal() {
Log out
</Button>
</div>
<PaywallHeader />
{recovery.paymentFailed ? (
<PaymentRecoveryCard
payment={recovery.payment}
fallback="Update your payment method in billing to continue. Your chats, agents and results are saved."
/>
) : (
<>
<PaywallHeader />
<div className="relative mt-2 w-full">
{isLoading || trial.isLoading ? (
<div className="grid w-full grid-cols-1 gap-4 px-[1rem] md:grid-cols-3 md:px-0">
<Skeleton className="h-[26rem] rounded-2xl" />
<Skeleton className="h-[26rem] rounded-2xl" />
<Skeleton className="h-[26rem] rounded-2xl" />
</div>
) : plans.length === 0 ? (
<div className="flex flex-col items-center gap-3">
<p className="text-center text-sm text-zinc-500">
Subscriptions are temporarily unavailable. Please try again
shortly.
</p>
<Button
variant="secondary"
size="small"
onClick={retryLoadPlans}
loading={isRetryingPlans}
>
Retry
</Button>
</div>
) : !trialOffer ? (
<div className="space-y-4">
<PaywallPlanChoices
plans={plans}
cycle={selectedCycle}
onCycle={setSelectedCycle}
onSelect={handleSelectPlan}
pending={isPending}
selectedTier={selectedTier}
/>
{!trial.trial?.eligible && (
<TrialCardContent returnTo="billing" controller={trial} />
<div className="relative mt-2 w-full">
{isLoading || trial.isLoading ? (
<div className="grid w-full grid-cols-1 gap-4 px-[1rem] md:grid-cols-3 md:px-0">
<Skeleton className="h-[26rem] rounded-2xl" />
<Skeleton className="h-[26rem] rounded-2xl" />
<Skeleton className="h-[26rem] rounded-2xl" />
</div>
) : plans.length === 0 ? (
<div className="flex flex-col items-center gap-3">
<p className="text-center text-sm text-zinc-500">
Subscriptions are temporarily unavailable. Please try
again shortly.
</p>
<Button
variant="secondary"
size="small"
onClick={retryLoadPlans}
loading={isRetryingPlans}
>
Retry
</Button>
</div>
) : !trialOffer ? (
<div className="space-y-4">
<PaywallPlanChoices
plans={plans}
cycle={selectedCycle}
onCycle={setSelectedCycle}
onSelect={handleSelectPlan}
pending={isPending}
selectedTier={selectedTier}
/>
{!trial.trial?.eligible && (
<TrialCardContent returnTo="billing" controller={trial} />
)}
</div>
) : (
<SubscriptionPlans
// The modal renders its own title above, so the organism
// contributes only the billing toggle.
header={null}
goalSurface="upgrade_modal"
plans={plans}
country={country}
billing={selectedCycle}
onBillingChange={setSelectedCycle}
onSelectPlan={handleSelectPlan}
isUpdatingTier={isPending}
selectedPlan={selectedTier}
trialOffer={trialOffer}
onStartTrial={trial.startTrial}
isStartingTrial={trial.isStarting}
trialError={trial.error}
trialStatus={
!trialOffer && (
<TrialCardContent
returnTo="billing"
controller={trial}
/>
)
}
/>
)}
</div>
) : (
<SubscriptionPlans
// The modal renders its own title above, so the organism
// contributes only the billing toggle.
header={null}
goalSurface="upgrade_modal"
plans={plans}
country={country}
billing={selectedCycle}
onBillingChange={setSelectedCycle}
onSelectPlan={handleSelectPlan}
isUpdatingTier={isPending}
selectedPlan={selectedTier}
trialOffer={trialOffer}
onStartTrial={trial.startTrial}
isStartingTrial={trial.isStarting}
trialError={trial.error}
trialStatus={
!trialOffer && (
<TrialCardContent returnTo="billing" controller={trial} />
)
}
/>
)}
</div>
</>
)}
</div>
<Button variant="ghost" className="mt-3 w-full" onClick={handleClose}>
Back to billing
@@ -0,0 +1,149 @@
import { getGetTrialsGetTrialStatusMockHandler200 } from "@/app/api/__generated__/endpoints/trials/trials.msw";
import { server } from "@/mocks/mock-server";
import { render, screen } from "@/tests/integrations/test-utils";
import {
setTrialUser,
trialResponse,
} from "@/tests/integrations/trial-fixtures";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
const mockUseGetSubscriptionStatus = vi.fn();
const mockUseUpdateSubscriptionTier = vi.fn();
vi.mock("@/app/api/__generated__/endpoints/credits/credits", () => ({
useGetSubscriptionStatus: (opts: unknown) =>
mockUseGetSubscriptionStatus(opts),
useUpdateSubscriptionTier: () => mockUseUpdateSubscriptionTier(),
getGetSubscriptionStatusQueryKey: () => ["/api/credits/subscription"],
}));
vi.mock("@posthog/react", () => ({
useFeatureFlagVariantKey: () => undefined,
usePostHog: () => ({ capture: vi.fn() }),
}));
function MockDialog({
title,
children,
}: {
title: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div role="dialog">
<div>{title}</div>
{children}
</div>
);
}
function MockDialogContent({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}
function MockDialogFooter({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}
MockDialog.Content = MockDialogContent;
MockDialog.Footer = MockDialogFooter;
vi.mock("@/components/molecules/Dialog/Dialog", () => ({
Dialog: MockDialog,
}));
vi.mock("@/lib/auth/hooks/useAuth", () => ({
useAuth: () => ({
validateSession: vi.fn().mockResolvedValue(true),
isLoggedIn: true,
}),
}));
vi.mock("@/providers/onboarding/onboarding-provider", () => ({
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
usePathname: () => "/copilot",
useSearchParams: () => new URLSearchParams(),
useParams: () => ({}),
}));
import { PaywallModal } from "../PaywallModal";
const PAYMENT_URL = "https://invoice.stripe.com/i/acct_1/test_inv_1";
beforeEach(() => {
setTrialUser();
mockUseGetSubscriptionStatus.mockReturnValue({
data: { tier: "NO_TIER", tier_costs: { PRO: 5000, MAX: 32000 } },
isLoading: false,
isFetching: false,
refetch: vi.fn(),
});
mockUseUpdateSubscriptionTier.mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
});
});
afterEach(() => {
setTrialUser(null);
vi.restoreAllMocks();
});
test("a failed first trial charge pays the open invoice instead of choosing a plan", async () => {
server.use(
getGetTrialsGetTrialStatusMockHandler200(
trialResponse({
active: false,
status: "past_due",
payment_due: {
amount_due: 5000,
currency: "usd",
payment_url: PAYMENT_URL,
},
}),
),
);
render(<PaywallModal />);
const pay = await screen.findByRole("link", { name: "Pay $50 now" });
expect(pay.getAttribute("href")).toBe(PAYMENT_URL);
expect(screen.getByText("Your payment didn’t go through")).toBeDefined();
for (const amount of screen.getAllByText(/\$50/)) {
expect(amount.closest(".sentry-unmask, [data-sentry-unmask]")).toBeNull();
}
expect(screen.queryByText("Choose a plan to keep working")).toBeNull();
expect(screen.queryByRole("region", { name: "Pro plan" })).toBeNull();
expect(screen.queryByRole("button", { name: /^Start/ })).toBeNull();
expect(screen.getByRole("button", { name: "Back to billing" })).toBeDefined();
});
test("sends a failed charge without a readable invoice to billing", async () => {
server.use(
getGetTrialsGetTrialStatusMockHandler200(
trialResponse({ active: false, status: "past_due", payment_due: null }),
),
);
render(<PaywallModal />);
expect(
await screen.findByText(
"Update your payment method in billing to continue. Your chats, agents and results are saved.",
),
).toBeDefined();
expect(screen.queryByRole("button", { name: /^Start/ })).toBeNull();
expect(screen.getByRole("button", { name: "Back to billing" })).toBeDefined();
});
test("keeps plan choices for a trial that simply ended", async () => {
server.use(
getGetTrialsGetTrialStatusMockHandler200(
trialResponse({ active: false, status: "expired" }),
),
);
render(<PaywallModal />);
expect(await screen.findByRole("region", { name: "Pro plan" })).toBeDefined();
expect(screen.queryByText("Your payment didn’t go through")).toBeNull();
});
@@ -4,6 +4,7 @@ import type { SubscriptionTier } from "@/app/api/__generated__/models/subscripti
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { PaymentDueAction } from "@/components/organisms/UsageExperience/PaymentDueAction";
import { PlanOffer } from "@/components/organisms/UsageExperience/PlanOffer";
import { ProviderContinuation } from "@/components/organisms/UsageExperience/ProviderContinuation";
import { UsageMeter } from "@/components/organisms/UsageExperience/UsageMeter";
@@ -12,6 +13,7 @@ import {
getUsageOfferAction,
type UsageExperience,
} from "@/services/usageExperience/helpers";
import { formatPaymentDue } from "@/services/usageExperience/payment";
import { usagePresentation } from "@/services/usageExperience/presentation";
import { useRouter } from "next/navigation";
import type { ComponentProps } from "react";
@@ -62,6 +64,7 @@ export function RateLimitResetDialog({
},
});
const copy = usagePresentation(model, offer?.usageMultiplier);
const payment = formatPaymentDue(model.payment);
const fallbackTitle =
failureWindow === "trial"
? "You’ve used your trial allowance"
@@ -127,6 +130,7 @@ export function RateLimitResetDialog({
<>
<Text
variant="body"
unmask={!payment}
className="mb-5 !text-sm !leading-6 !text-zinc-500"
>
{copy.description}
@@ -158,11 +162,17 @@ export function RateLimitResetDialog({
/>
)}
{isBillingEnabled &&
model.inactiveTrialStatus === "payment_failed" && (
model.inactiveTrialStatus === "payment_failed" &&
(payment ? (
<PaymentDueAction
url={payment.url}
label={payment.actionLabel}
/>
) : (
<Button className="w-full" onClick={upgrade}>
Review billing details
</Button>
)}
))}
{isBillingEnabled && model.isTopTier && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-zinc-200 p-4">
<div>
@@ -135,6 +135,49 @@ describe("Usage limit dialog", () => {
).toBeDefined();
});
it("pays the open trial invoice straight from the dialog", () => {
const onUpgrade = vi.fn();
render(
<RateLimitResetDialog
isOpen
onClose={vi.fn()}
experience={getUsageExperience(
{ ...usage, tier: "NO_TIER" },
{
active: false,
converted: false,
status: "past_due",
offer: { tier: "PRO" },
ends_at: new Date(2026, 9, 12, 12),
payment_due: {
amount_due: 5000,
currency: "usd",
payment_url: "https://invoice.stripe.com/i/acct_1/test_inv_1",
},
},
)}
onUpgrade={onUpgrade}
/>,
);
expect(
screen.getByText(
"Pro was due to start on Oct 12, but the $50 charge didn’t go through. Pay now and Pro starts right away. Your chats, agents and results are saved.",
),
).toBeDefined();
for (const amount of screen.getAllByText(/\$50/)) {
expect(amount.closest(".sentry-unmask, [data-sentry-unmask]")).toBeNull();
}
const pay = screen.getByRole("link", { name: "Pay $50 now" });
expect(pay.getAttribute("href")).toBe(
"https://invoice.stripe.com/i/acct_1/test_inv_1",
);
expect(pay.getAttribute("target")).toBe("_blank");
expect(
screen.queryByRole("button", { name: "Review billing details" }),
).toBeNull();
expect(onUpgrade).not.toHaveBeenCalled();
});
it.each([true, false])(
"provides failed-payment recovery only when billing is enabled (%s)",
(isBillingEnabled) => {
@@ -5,7 +5,9 @@ import { Button } from "@/components/atoms/Button/Button";
import { Icon } from "@/components/atoms/Icon/Icon";
import { Text } from "@/components/atoms/Text/Text";
import { getUsageOfferAction } from "@/services/usageExperience/helpers";
import { formatPaymentDue } from "@/services/usageExperience/payment";
import { usagePresentation } from "@/services/usageExperience/presentation";
import { PaymentDueAction } from "@/components/organisms/UsageExperience/PaymentDueAction";
import { ProviderContinuation } from "@/components/organisms/UsageExperience/ProviderContinuation";
import type { UsageNoticeContext } from "./useUsageLimitReachedCard";
import { useUsageLimitReachedCard } from "./useUsageLimitReachedCard";
@@ -17,6 +19,8 @@ export function UsageLimitReachedCard(props: UsageNoticeContext = {}) {
const copy = usagePresentation(state.experience, state.offer.usageMultiplier);
const isOffer =
state.isBillingEnabled && state.experience.targetTier && !state.isError;
const due = formatPaymentDue(state.experience.payment);
const payment = state.isBillingEnabled && !state.isError ? due : null;
return (
<section
role="status"
@@ -52,7 +56,11 @@ export function UsageLimitReachedCard(props: UsageNoticeContext = {}) {
? "We couldn’t check your usage."
: copy.noticeTitle}
</Text>
<Text variant="small" className="mt-2 !leading-5 !text-zinc-500">
<Text
variant="small"
unmask={!due}
className="mt-2 !leading-5 !text-zinc-500"
>
{state.isRefreshing
? "Refreshing your plan and usage…"
: state.isError
@@ -77,6 +85,16 @@ export function UsageLimitReachedCard(props: UsageNoticeContext = {}) {
)}
</div>
)}
{payment && (
<div className="mt-4">
<PaymentDueAction
url={payment.url}
label={payment.actionLabel}
size="small"
layout="inline"
/>
</div>
)}
{state.alternative && (
<ProviderContinuation
name={state.alternative.display_name}
@@ -181,4 +181,34 @@ describe("Usage notice", () => {
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(await screen.findByText("You’ve hit today’s limit")).toBeDefined();
});
it("pays a failed first trial charge from the notice", async () => {
setTrialUser();
setup("NO_TIER");
server.use(
http.get("*/api/credits/trial", () =>
HttpResponse.json(
trialResponse({
active: false,
status: "past_due",
ends_at: new Date(2026, 9, 12, 12),
payment_due: {
amount_due: 5000,
currency: "usd",
payment_url: "https://invoice.stripe.com/i/acct_1/test_inv_1",
},
}),
),
),
);
render(<UsageLimitReachedCard />);
const pay = await screen.findByRole("link", { name: "Pay $50 now" });
expect(pay.getAttribute("href")).toBe(
"https://invoice.stripe.com/i/acct_1/test_inv_1",
);
expect(screen.getByText("Your payment didn’t go through")).toBeDefined();
for (const amount of screen.getAllByText(/\$50/)) {
expect(amount.closest(".sentry-unmask, [data-sentry-unmask]")).toBeNull();
}
expect(screen.queryByRole("button", { name: /^Start/ })).toBeNull();
});
});
@@ -0,0 +1,84 @@
import { http, HttpResponse } from "msw";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { getGetTrialsGetTrialStatusMockHandler200 } from "@/app/api/__generated__/endpoints/trials/trials.msw";
import { server } from "@/mocks/mock-server";
import { render, screen } from "@/tests/integrations/test-utils";
import {
setTrialUser,
trialResponse,
} from "@/tests/integrations/trial-fixtures";
import SettingsBillingPage from "../page";
vi.mock("@/hooks/useIsBillingEnabled", () => ({
useIsBillingEnabled: () => true,
useBillingStatus: () => ({ isBillingEnabled: true, isResolved: true }),
}));
const PAYMENT_URL = "https://invoice.stripe.com/i/acct_1/test_inv_1";
beforeEach(() => setTrialUser());
afterEach(() => setTrialUser(null));
function mockPastDueTrial(status: string, paymentDue: boolean) {
server.use(
getGetTrialsGetTrialStatusMockHandler200(
trialResponse({
active: false,
status,
payment_due: paymentDue
? { amount_due: 5413, currency: "usd", payment_url: PAYMENT_URL }
: null,
}),
),
http.get("*/api/credits/subscription", () =>
HttpResponse.json({
tier: "NO_TIER",
monthly_cost: 0,
has_active_stripe_subscription: false,
}),
),
http.get("*/api/credits/invoices", () => HttpResponse.json([])),
);
}
it.each(["past_due", "unpaid"])(
"gives a %s trial one message and one way to pay the open invoice",
async (status) => {
mockPastDueTrial(status, true);
render(<SettingsBillingPage />);
const pay = await screen.findByRole("link", { name: "Pay $54.13 now" });
expect(pay.getAttribute("href")).toBe(PAYMENT_URL);
expect(pay.getAttribute("target")).toBe("_blank");
expect(pay.closest(".sentry-unmask")).toBeNull();
expect(screen.getByText("Your payment didn’t go through")).toBeDefined();
expect(
screen.getByText(
"Pro was due to start on Sep 17, but the $54.13 charge didn’t go through. Pay now and Pro starts right away. Your chats, agents and results are saved.",
),
).toBeDefined();
for (const amount of screen.getAllByText(/\$54\.13/)) {
expect(amount.closest(".sentry-unmask, [data-sentry-unmask]")).toBeNull();
}
expect(screen.queryByText("Your trial has ended")).toBeNull();
expect(
screen.queryByText("Pick a plan to continue using AutoGPT."),
).toBeNull();
expect(screen.queryByRole("button", { name: /^Start/ })).toBeNull();
expect(screen.queryByRole("region", { name: "Usage" })).toBeNull();
expect(screen.queryByText("Update payment method")).toBeNull();
expect(screen.getByText("Manage payment method")).toBeDefined();
},
);
it("points to the payment method row when the open invoice can't be read", async () => {
mockPastDueTrial("past_due", false);
render(<SettingsBillingPage />);
expect(
await screen.findByText(
"Update your payment method below to continue. Your chats, agents and results are saved.",
),
).toBeDefined();
expect(screen.queryByRole("link", { name: /^Pay / })).toBeNull();
expect(screen.queryByRole("button", { name: /^Start/ })).toBeNull();
expect(screen.getByText("Manage payment method")).toBeDefined();
});
@@ -18,7 +18,7 @@ vi.mock("@/hooks/useIsBillingEnabled", () => ({
beforeEach(() => setTrialUser());
afterEach(() => setTrialUser(null));
it.each(["past_due", "unpaid", "incomplete_expired", "trialing", "canceled"])(
it.each(["incomplete_expired", "trialing", "canceled"])(
"keeps plan selection available for an inactive %s trial",
async (status) => {
server.use(
@@ -52,18 +52,14 @@ export function AutopilotUsageCard({ index = 0 }: Props) {
{state.inactive || state.unlimited ? (
<div className="my-auto py-8">
<Text variant="large-medium" className="mb-2">
{state.paymentFailed
? "Your payment needs attention."
: state.unlimited
? "No usage limit"
: "Choose a plan to keep working"}
{state.unlimited
? "No usage limit"
: "Choose a plan to keep working"}
</Text>
<Text variant="small" tone="secondary" className="leading-6">
{state.paymentFailed
? "Review your payment details to continue. Your conversations and agents stay saved."
: state.unlimited
? "Your plan has no daily or weekly usage limits."
: "Choose a plan to continue working with your saved conversations and agents."}
{state.unlimited
? "Your plan has no daily or weekly usage limits."
: "Choose a plan to continue working with your saved conversations and agents."}
</Text>
</div>
) : (
@@ -65,11 +65,6 @@ export function useAutopilotUsageCard() {
lifetime: experience.isLifetimeTrial,
blocked: experience.blocked,
inactive: experience.tier === "NO_TIER",
paymentFailed:
experience.tier === "NO_TIER" &&
!trial?.active &&
!trial?.converted &&
["past_due", "unpaid"].includes(trial?.status ?? ""),
hasUsage: Boolean(featured || secondary),
unlimited: Boolean(
usage &&
@@ -23,11 +23,6 @@ export function BillingOffer({ controller }: Props) {
const { experience, trial, subscription, isError } = useUsageExperience();
const activation = useProActivation();
const isTrial = experience.isActiveTrial;
const paymentFailed =
experience.tier === "NO_TIER" &&
!trial?.active &&
!trial?.converted &&
["past_due", "unpaid"].includes(trial?.status ?? "");
const target = isTrial
? trial?.offer?.tier === "PRO"
? "Pro"
@@ -47,18 +42,6 @@ export function BillingOffer({ controller }: Props) {
ready
/>
);
if (paymentFailed)
return (
<SupportOffer
eyebrow="Payment"
footer="Your conversations and agents stay saved."
title="Your payment didn’t go through"
description="Your trial payment needs attention. Review your payment details to continue with your saved work."
label="Update payment method"
onAction={controller?.onManage}
disabled={!controller?.canManagePortal}
/>
);
if (isError && experience.tier !== "NO_TIER")
return (
<SupportOffer
@@ -3,6 +3,7 @@
import { Text } from "@/components/atoms/Text/Text";
import { TrialCard } from "@/components/organisms/TrialCard/TrialCard";
import { TrialCheckoutConfirmation } from "@/components/organisms/TrialCard/TrialCheckoutConfirmation";
import { PaymentRecoveryCard } from "@/components/organisms/UsageExperience/PaymentRecoveryCard";
import { BillingOffer } from "./BillingOffer";
import { AutopilotUsageCard } from "./AutopilotUsageCard/AutopilotUsageCard";
@@ -24,11 +25,19 @@ export function SubscriptionTab({ isPlanCheckoutReturn = false }: Props) {
isConfirmingPlan,
isLoading,
hasPlanChoices,
paymentFailed,
payment,
} = useSubscriptionTab(isPlanCheckoutReturn);
return (
<div className="flex flex-col gap-4">
<TrialCheckoutConfirmation />
<div className="flex flex-col gap-3 empty:hidden">
{paymentFailed ? (
<PaymentRecoveryCard
payment={payment}
fallback="Update your payment method below to continue. Your chats, agents and results are saved."
/>
) : null}
{showTrialCard ? <TrialCard /> : null}
{isConfirmingPlan ? (
<Text variant="small" tone="secondary" role="status" className="px-4">
@@ -40,7 +49,7 @@ export function SubscriptionTab({ isPlanCheckoutReturn = false }: Props) {
) : null}
</div>
{showPlan ? <YourPlanCard index={0} showUsage /> : null}
{!showPlan && !isLoading ? (
{!showPlan && !isLoading && !paymentFailed ? (
<div
className={
hasPlanChoices
@@ -7,23 +7,17 @@ import { Text } from "@/components/atoms/Text/Text";
export function SupportOffer({
eyebrow,
footer,
title,
description,
label,
href,
onAction,
disabled = false,
ready = false,
}: {
eyebrow: string;
footer?: string;
title: string;
description: string;
label: string;
href?: string;
onAction?: () => void;
disabled?: boolean;
href: string;
ready?: boolean;
}) {
return (
@@ -49,36 +43,15 @@ export function SupportOffer({
<Text variant="small" tone="secondary" className="mb-7 leading-6">
{description}
</Text>
{href ? (
<Button
variant="secondary"
size="large"
className="mt-auto w-full"
as="NextLink"
href={href}
>
{label}
</Button>
) : (
<Button
variant="secondary"
size="large"
className="mt-auto w-full"
onClick={onAction}
disabled={disabled}
>
{label}
</Button>
)}
{footer && (
<Text
variant="small"
tone="secondary"
className="mt-2.5 text-center text-xs leading-5"
>
{footer}
</Text>
)}
<Button
variant="secondary"
size="large"
className="mt-auto w-full"
as="NextLink"
href={href}
>
{label}
</Button>
</section>
);
}
@@ -2,6 +2,7 @@
import { useGetSubscriptionStatus } from "@/app/api/__generated__/endpoints/credits/credits";
import { useTrialStatus } from "@/services/trials/useTrialStatus";
import { usePaymentRecovery } from "@/services/usageExperience/usePaymentRecovery";
import { isPaidTier, readTier } from "./helpers";
import { getCancelPendingOffer } from "./TrialPlanChoices/helpers";
@@ -11,6 +12,7 @@ export function useSubscriptionTab(isPlanCheckoutReturn: boolean) {
const { data: trial, isLoading } = useTrialStatus();
const tier = useGetSubscriptionStatus({ query: { select: readTier } });
const { isConfirmingPlan } = usePlanCheckoutReturn(isPlanCheckoutReturn);
const { paymentFailed, payment } = usePaymentRecovery(tier.data);
// An ended trial that a paid plan replaced is history, not the plan; it
// waits for the tier so it never flashes above the plan card.
const isEndedTrial = Boolean(trial && !trial.eligible && !trial.active);
@@ -28,10 +30,15 @@ export function useSubscriptionTab(isPlanCheckoutReturn: boolean) {
return {
isLoading,
paymentFailed,
payment,
hasPlanChoices: Boolean(cancelPendingOffer),
showPlan:
!isLoading && (!trial?.active || trial.converted || isSupersededTrial),
showTrialCard: !(isEndedTrial && isReplaced) && !isSupersededTrial,
!paymentFailed &&
!isLoading &&
(!trial?.active || trial.converted || isSupersededTrial),
showTrialCard:
!paymentFailed && !(isEndedTrial && isReplaced) && !isSupersededTrial,
planChoicesOffer: isConfirmingPlan ? null : cancelPendingOffer,
isConfirmingPlan: Boolean(cancelPendingOffer) && isConfirmingPlan,
};
@@ -32979,6 +32979,16 @@
],
"title": "TrialOfferResponse"
},
"TrialPaymentDue": {
"properties": {
"amount_due": { "type": "integer", "title": "Amount Due" },
"currency": { "type": "string", "title": "Currency" },
"payment_url": { "type": "string", "title": "Payment Url" }
},
"type": "object",
"required": ["amount_due", "currency", "payment_url"],
"title": "TrialPaymentDue"
},
"TrialRejectionReason": {
"type": "string",
"enum": ["intro_offer_already_used", "card_verification_failed"],
@@ -33040,6 +33050,12 @@
"type": "boolean",
"title": "Onboarding Credits Previously Received",
"default": false
},
"payment_due": {
"anyOf": [
{ "$ref": "#/components/schemas/TrialPaymentDue" },
{ "type": "null" }
]
}
},
"type": "object",
@@ -0,0 +1,53 @@
import { LinkSquare01Icon } from "@hugeicons/core-free-icons";
import { Button } from "@/components/atoms/Button/Button";
import { Icon } from "@/components/atoms/Icon/Icon";
import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
interface Props {
url: string;
label: string;
size?: "small" | "large";
layout?: "stacked" | "inline";
}
export function PaymentDueAction({
url,
label,
size = "large",
layout = "stacked",
}: Props) {
const stacked = layout === "stacked";
return (
<div
className={cn(
stacked ? "space-y-2.5" : "flex flex-wrap items-center gap-x-4 gap-y-2",
)}
>
<Button
as="NextLink"
href={url}
target="_blank"
rel="noopener noreferrer"
size={size}
unmask={false}
className={stacked ? "w-full" : undefined}
rightIcon={
<Icon
icon={LinkSquare01Icon}
size={size === "small" ? 14 : 16}
aria-hidden="true"
/>
}
>
{label}
</Button>
<Text
variant="small"
className={cn("!text-xs !text-zinc-500", stacked && "text-center")}
>
Opens a secure Stripe page. You can use a different card there.
</Text>
</div>
);
}
@@ -0,0 +1,52 @@
import { CreditCardIcon } from "@hugeicons/core-free-icons";
import { Icon } from "@/components/atoms/Icon/Icon";
import { Text } from "@/components/atoms/Text/Text";
import type { formatPaymentDue } from "@/services/usageExperience/payment";
import { PaymentDueAction } from "./PaymentDueAction";
interface Props {
payment: ReturnType<typeof formatPaymentDue>;
fallback: string;
}
export function PaymentRecoveryCard({ payment, fallback }: Props) {
return (
<section
aria-label="Payment"
className="w-full rounded-2xl border border-zinc-200 bg-white p-6 text-left"
>
<div className="flex items-center gap-2.5">
<span className="flex size-7 items-center justify-center rounded-lg bg-white text-zinc-600 ring-1 ring-zinc-200">
<Icon icon={CreditCardIcon} size={17} />
</span>
<Text variant="small-medium" className="text-zinc-600">
Payment
</Text>
</div>
<Text
variant="h4"
as="h2"
className="mb-3 mt-4 text-[23px] leading-8 tracking-[-0.04em]"
>
Your payment didn’t go through
</Text>
<Text
variant="small"
tone="secondary"
unmask={!payment}
className="max-w-[38rem] leading-6"
>
{payment?.description ?? fallback}
</Text>
{payment ? (
<div className="mt-5">
<PaymentDueAction
url={payment.url}
label={payment.actionLabel}
layout="inline"
/>
</div>
) : null}
</section>
);
}
@@ -134,4 +134,29 @@ describe("usage experience", () => {
});
},
);
it("carries the open invoice only for a failed first charge", () => {
const payment_due = {
amount_due: 5000,
currency: "usd",
payment_url: "https://invoice.stripe.com/i/acct_1/test_inv_1",
};
expect(
getUsageExperience(
{ ...usage, tier: "NO_TIER" },
{
...trial,
active: false,
status: "past_due",
offer: { tier: "PRO" },
payment_due,
},
).payment,
).toEqual({ ...payment_due, plan: "PRO", startsOn: trial.ends_at });
expect(
getUsageExperience(
{ ...usage, tier: "NO_TIER" },
{ ...trial, active: false, status: "expired", payment_due },
).payment,
).toBeNull();
});
});
@@ -89,4 +89,37 @@ describe("usage messages", () => {
description: "Your chats, agents and results are saved.",
});
});
it("states the amount and start date of a failed first trial charge", () => {
const experience = getUsageExperience(
{ tier: "NO_TIER" },
{
active: false,
converted: false,
status: "past_due",
offer: { tier: "PRO" },
ends_at: new Date(2026, 9, 12, 12),
payment_due: {
amount_due: 5413,
currency: "usd",
payment_url: "https://invoice.stripe.com/i/acct_1/test_inv_1",
},
},
"NO_TIER",
);
expect(usagePresentation(experience)).toMatchObject({
title: "Your payment didn’t go through",
description:
"Pro was due to start on Oct 12, but the $54.13 charge didn’t go through. Pay now and Pro starts right away. Your chats, agents and results are saved.",
});
});
it("keeps the billing route when the open invoice is unavailable", () => {
const experience = getUsageExperience(
{ tier: "NO_TIER" },
{ active: false, converted: false, status: "past_due" },
"NO_TIER",
);
expect(usagePresentation(experience).description).toBe(
"Review your billing details to continue. Your chats, agents and results are saved.",
);
});
});
@@ -16,6 +16,21 @@ interface Trial {
allowance_used_percent?: number | null;
ends_at?: string | Date | null;
usage_policy?: string | null;
payment_due?: {
amount_due: number;
currency: string;
payment_url: string;
} | null;
}
function getTrialPayment(trial?: Trial | null) {
const due = trial?.payment_due;
if (!due) return null;
return {
...due,
plan: trial.offer?.tier ?? null,
startsOn: trial.ends_at ?? null,
};
}
export function getUsageExperience(
@@ -70,6 +85,8 @@ export function getUsageExperience(
trial?.offer?.tier === "PRO",
trialPercent,
inactiveTrialStatus,
payment:
inactiveTrialStatus === "payment_failed" ? getTrialPayment(trial) : null,
trialEndsAt: isActiveTrial ? (trial?.ends_at ?? null) : null,
blocked: window !== null || !!inactiveTrialStatus,
window,
@@ -0,0 +1,19 @@
import { formatTierLabel } from "@/app/(platform)/copilot/components/usageHelpers";
import {
formatOfferPrice,
formatTrialDate,
type UsageExperience,
} from "./helpers";
export function formatPaymentDue(payment: UsageExperience["payment"]) {
if (!payment) return null;
const amount = formatOfferPrice(payment.amount_due, payment.currency);
if (!amount) return null;
const plan = formatTierLabel(payment.plan) ?? "Your plan";
const date = formatTrialDate(payment.startsOn);
return {
url: payment.payment_url,
actionLabel: `Pay ${amount} now`,
description: `${plan} was due to start${date ? ` on ${date}` : ""}, but the ${amount} charge didn’t go through. Pay now and ${plan === "Your plan" ? "your plan" : plan} starts right away. Your chats, agents and results are saved.`,
};
}
@@ -6,6 +6,7 @@ import {
formatUsageDate,
type UsageExperience,
} from "./helpers";
import { formatPaymentDue } from "./payment";
export function usagePresentation(
experience: UsageExperience,
@@ -29,7 +30,8 @@ export function usagePresentation(
title,
noticeTitle: title,
description: payment
? "Review your billing details to continue. Your chats, agents and results are saved."
? (formatPaymentDue(experience.payment)?.description ??
"Review your billing details to continue. Your chats, agents and results are saved.")
: canceled
? "Your trial won’t turn into a paid subscription. Your chats, agents and results are saved whenever you’re ready to choose a plan."
: "Your chats, agents and results are saved. Choose a plan to keep going.",
@@ -0,0 +1,49 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { getGetV2GetCopilotUsageQueryKey } from "@/app/api/__generated__/endpoints/chat/chat";
import { getGetSubscriptionStatusQueryKey } from "@/app/api/__generated__/endpoints/credits/credits";
import type { TrialStatusResponse } from "@/app/api/__generated__/models/trialStatusResponse";
import { useTrialStatus } from "@/services/trials/useTrialStatus";
import { getUsageExperience } from "./helpers";
import { formatPaymentDue } from "./payment";
// The open invoice is paid in another tab, so keep checking until the
// conversion lands; the trial query already pauses while this tab is hidden.
const PAYMENT_CHECK_MS = 15_000;
export function usePaymentRecovery(tier: string | null | undefined) {
const queryClient = useQueryClient();
const trial = useTrialStatus({
refetchInterval: (current) =>
isAwaitingPayment(current) ? PAYMENT_CHECK_MS : false,
});
const experience = getUsageExperience(null, trial.data, tier);
const paymentFailed = experience.inactiveTrialStatus === "payment_failed";
const wasFailed = useRef(paymentFailed);
useEffect(() => {
if (wasFailed.current && !paymentFailed) {
void queryClient.invalidateQueries({
queryKey: getGetSubscriptionStatusQueryKey(),
});
void queryClient.invalidateQueries({
queryKey: getGetV2GetCopilotUsageQueryKey(),
});
}
wasFailed.current = paymentFailed;
}, [paymentFailed, queryClient]);
return {
paymentFailed,
payment: paymentFailed ? formatPaymentDue(experience.payment) : null,
};
}
function isAwaitingPayment(trial: TrialStatusResponse | undefined) {
return Boolean(
trial &&
!trial.active &&
!trial.converted &&
["past_due", "unpaid"].includes(trial.status ?? ""),
);
}
@@ -26,6 +26,7 @@ export function trialResponse(overrides: Partial<TrialStatusResponse> = {}) {
active: true,
converted: false,
onboarding_credits_previously_received: false,
payment_due: null,
...overrides,
});
}