mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-11-09 04:49:50 -10:00
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:
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
|
||||
|
||||
+149
@@ -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();
|
||||
});
|
||||
+12
-2
@@ -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>
|
||||
|
||||
+43
@@ -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) => {
|
||||
|
||||
+19
-1
@@ -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}
|
||||
|
||||
+30
@@ -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();
|
||||
});
|
||||
});
|
||||
+84
@@ -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();
|
||||
});
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+6
-10
@@ -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>
|
||||
) : (
|
||||
|
||||
-5
@@ -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 &&
|
||||
|
||||
-17
@@ -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
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
+10
-37
@@ -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>
|
||||
);
|
||||
}
|
||||
+9
-2
@@ -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",
|
||||
|
||||
+53
@@ -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>
|
||||
);
|
||||
}
|
||||
+52
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user