feat(subscriptions): show experiment trial terms and refund control

Paywall now states the exact terms for the user's variant right above the
CTA: the trial length per selected plan, or "charged today + N-day
money-back guarantee" for pay_now. The billing screen shows a money-back
card with a confirm-to-refund button and the deadline while the pay_now
refund window is open. Spanish translations included.
This commit is contained in:
Víctor Falcón 2026-06-27 14:04:48 +02:00
parent 7c8528f48a
commit f47a5be798
3 changed files with 162 additions and 6 deletions

View File

@ -1,6 +1,16 @@
{
"This subscription is no longer eligible for a self-service refund.": "Esta suscripción ya no es elegible para una devolución automática.",
"Your payment was refunded, your subscription was canceled, and your bank connections were disconnected.": "Te hemos devuelto el pago, cancelado la suscripción y desconectado tus cuentas bancarias.",
"You'll be charged today": "Se te cobrará hoy",
"Changed your mind? Get a full refund yourself from Settings within the first :days days — no questions asked.": "¿Cambias de idea? Solicita tú mismo la devolución completa desde Ajustes durante los primeros :days días, sin preguntas.",
":days-day free trial": "Prueba gratis de :days días",
"You won't be charged until your :days-day trial ends. Cancel anytime before then.": "No se te cobrará hasta que termine tu prueba de :days días. Cancela cuando quieras antes de eso.",
"Money-back guarantee": "Garantía de devolución",
"Changed your mind? Request a full refund until :date. This cancels your subscription and disconnects your bank accounts — the data you already imported is kept.": "¿Cambias de idea? Solicita la devolución completa hasta el :date. Esto cancela tu suscripción y desconecta tus cuentas bancarias; los datos que ya importaste se conservan.",
"Changed your mind? Request a full refund. This cancels your subscription and disconnects your bank accounts — the data you already imported is kept.": "¿Cambias de idea? Solicita la devolución completa. Esto cancela tu suscripción y desconecta tus cuentas bancarias; los datos que ya importaste se conservan.",
"Confirm refund": "Confirmar devolución",
"Keep my plan": "Mantener mi plan",
"Request a refund": "Solicitar devolución",
"AI Categorization": "Categorización con IA",
"Let AI suggest categories for your transactions automatically.": "Deja que la IA sugiera categorías para tus transacciones automáticamente.",
"Allow AI categorization": "Permitir categorización con IA",

View File

@ -11,13 +11,13 @@ import {
store as storeConsent,
} from '@/routes/ai/consent';
import { billing } from '@/routes/settings';
import { portal } from '@/routes/settings/billing';
import { portal, refund as refundRoute } from '@/routes/settings/billing';
import { checkout } from '@/routes/subscribe';
import { type BreadcrumbItem, type SharedData } from '@/types';
import { Plan } from '@/types/pricing';
import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n';
import { Head, usePage } from '@inertiajs/react';
import { Head, router, usePage } from '@inertiajs/react';
import axios from 'axios';
import {
CheckIcon,
@ -245,16 +245,95 @@ function UpgradeSection({
);
}
interface RefundInfo {
canSelfRefund: boolean;
deadline: string | null;
}
function RefundCard({
deadline,
locale,
}: {
deadline: string | null;
locale: string;
}) {
const [confirming, setConfirming] = useState(false);
const [processing, setProcessing] = useState(false);
const deadlineLabel = deadline
? new Date(deadline).toLocaleDateString(locale, {
day: 'numeric',
month: 'long',
})
: null;
const submit = () => {
setProcessing(true);
router.post(
refundRoute.url(),
{},
{ onFinish: () => setProcessing(false) },
);
};
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-5 dark:border-amber-900 dark:bg-amber-950/30">
<h4 className="font-medium text-amber-900 dark:text-amber-200">
{__('Money-back guarantee')}
</h4>
<p className="mt-1 text-sm text-amber-800/80 dark:text-amber-300/80">
{deadlineLabel
? __(
'Changed your mind? Request a full refund until :date. This cancels your subscription and disconnects your bank accounts — the data you already imported is kept.',
{ date: deadlineLabel },
)
: __(
'Changed your mind? Request a full refund. This cancels your subscription and disconnects your bank accounts — the data you already imported is kept.',
)}
</p>
{confirming ? (
<div className="mt-4 flex flex-wrap gap-2">
<Button
variant="destructive"
disabled={processing}
onClick={submit}
>
{__('Confirm refund')}
</Button>
<Button
variant="ghost"
disabled={processing}
onClick={() => setConfirming(false)}
>
{__('Keep my plan')}
</Button>
</div>
) : (
<Button
variant="outline"
className="mt-4"
onClick={() => setConfirming(true)}
>
{__('Request a refund')}
</Button>
)}
</div>
);
}
function SubscribedSection({
isDemoAccount,
defaultPlan,
currency,
locale,
refund,
}: {
isDemoAccount: boolean;
defaultPlan: Plan | undefined;
currency: string;
locale: string;
refund?: RefundInfo;
}) {
return (
<div className="space-y-6">
@ -302,6 +381,10 @@ function SubscribedSection({
</a>
)}
</div>
{refund?.canSelfRefund && (
<RefundCard deadline={refund.deadline} locale={locale} />
)}
</div>
);
}
@ -393,8 +476,8 @@ function AiConsentSection({
}
export default function Billing() {
const { auth, pricing, locale, features, hasAiConsent } = usePage<
SharedData & { hasAiConsent: boolean }
const { auth, pricing, locale, features, hasAiConsent, refund } = usePage<
SharedData & { hasAiConsent: boolean; refund?: RefundInfo }
>().props;
const isDemoAccount = auth?.isDemoAccount ?? false;
const hasProPlan = auth?.hasProPlan ?? false;
@ -413,6 +496,7 @@ export default function Billing() {
defaultPlan={defaultPlan}
currency={pricing.currency}
locale={locale}
refund={refund}
/>
) : (
<UpgradeSection

View File

@ -32,10 +32,62 @@ interface PaywallStats {
balancesByCurrency: Record<string, number>;
}
interface ExperimentOffer {
variant: string;
payNow: boolean;
refundWindowDays: number;
trialDays: Record<string, number>;
}
interface PaywallPageProps extends SharedData {
stats: PaywallStats;
canUseFreePlan: boolean;
canManageConnectionsForFreePlan: boolean;
offer: ExperimentOffer;
}
function TrialTerms({
offer,
planKey,
}: {
offer: ExperimentOffer;
planKey: string;
}) {
if (offer.payNow) {
return (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-center text-sm dark:border-emerald-900 dark:bg-emerald-950/40">
<p className="font-medium text-emerald-900 dark:text-emerald-200">
{__("You'll be charged today")}
</p>
<p className="mt-1 text-emerald-800/80 dark:text-emerald-300/80">
{__(
'Changed your mind? Get a full refund yourself from Settings within the first :days days — no questions asked.',
{ days: offer.refundWindowDays },
)}
</p>
</div>
);
}
const days = offer.trialDays[planKey] ?? 0;
if (days <= 0) {
return null;
}
return (
<div className="rounded-lg border bg-muted/30 p-3 text-center text-sm">
<p className="font-medium">
{__(':days-day free trial', { days })}
</p>
<p className="mt-1 text-muted-foreground">
{__(
"You won't be charged until your :days-day trial ends. Cancel anytime before then.",
{ days },
)}
</p>
</div>
);
}
function getEquivalentBillingLabel(
@ -335,12 +387,14 @@ function PricingSection({
currency,
canUseFreePlan,
canManageConnectionsForFreePlan,
offer,
}: {
planEntries: [string, Plan][];
defaultPlan: string;
currency: string;
canUseFreePlan: boolean;
canManageConnectionsForFreePlan: boolean;
offer: ExperimentOffer;
}) {
const [selectedPlan, setSelectedPlan] = useState(defaultPlan);
const [freeButtonVisible, setFreeButtonVisible] = useState(false);
@ -379,6 +433,8 @@ function PricingSection({
))}
</div>
<TrialTerms offer={offer} planKey={selectedPlan} />
<a href={checkout.url({ query: { plan: selectedPlan } })}>
<Button
className="w-full bg-emerald-600 py-6 hover:bg-emerald-700 dark:bg-emerald-600 dark:hover:bg-emerald-700"
@ -428,8 +484,13 @@ function PricingSection({
}
export default function Paywall() {
const { pricing, stats, canUseFreePlan, canManageConnectionsForFreePlan } =
usePage<PaywallPageProps>().props;
const {
pricing,
stats,
canUseFreePlan,
canManageConnectionsForFreePlan,
offer,
} = usePage<PaywallPageProps>().props;
const planEntries = Object.entries(pricing.plans);
if (planEntries.length === 0) {
@ -454,6 +515,7 @@ export default function Paywall() {
canManageConnectionsForFreePlan={
canManageConnectionsForFreePlan
}
offer={offer}
/>
</div>
</div>