feat(billing): implement robust in-app subscription cancellation & fix CI/CD socket port typo

This commit is contained in:
Antigravity
2026-05-28 20:50:11 +00:00
parent f5608372dc
commit 457c6fa626
22 changed files with 656 additions and 460 deletions

View File

@@ -24,7 +24,7 @@ interface BillingStatus {
hasStripeSubscription: boolean;
}
const billingEnabled = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true';
const billingEnabled = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' || process.env.NODE_ENV === 'development';
let stripePromise: ReturnType<typeof loadStripe> | null = null;
function getStripePromise() {
@@ -43,12 +43,14 @@ export function BillingPlans() {
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
const [checkoutLoading, setCheckoutLoading] = useState<Tier | null>(null);
const [portalLoading, setPortalLoading] = useState(false);
const [cancelLoading, setCancelLoading] = useState(false);
const [successBanner, setSuccessBanner] = useState<string | null>(null);
const { data: status, isLoading } = useQuery<BillingStatus>({
queryKey: ['billing', 'status'],
queryFn: async () => {
const res = await fetch('/api/billing/status');
const search = typeof window !== 'undefined' ? window.location.search : '';
const res = await fetch(`/api/billing/status${search}`);
if (!res.ok) throw new Error('Failed to fetch billing status');
return res.json();
},
@@ -69,6 +71,7 @@ export function BillingPlans() {
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const sessionId = params.get('session_id');
if (sessionId) {
const tier = status?.effectiveTier ?? 'Pro';
setSuccessBanner(t('billing.checkoutSuccessBody').replace('{tier}', tier));
@@ -112,10 +115,15 @@ export function BillingPlans() {
}
};
const handlePortal = async () => {
const handlePortal = async (action: 'portal' | 'cancel' | React.MouseEvent = 'portal') => {
const actualAction = typeof action === 'string' ? action : 'portal';
setPortalLoading(true);
try {
const res = await fetch('/api/billing/portal', { method: 'POST' });
const res = await fetch('/api/billing/portal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: actualAction }),
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || 'Failed to open billing portal.');
@@ -130,6 +138,34 @@ export function BillingPlans() {
}
};
const handleCancelSubscription = async () => {
const confirmMsg = t('billing.cancelConfirm') || "Êtes-vous sûr de vouloir résilier votre abonnement ? Vous conserverez vos accès Pro/Business jusqu'à la fin de la période en cours.";
if (!window.confirm(confirmMsg)) {
return;
}
setCancelLoading(true);
try {
const res = await fetch('/api/billing/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || 'Failed to cancel subscription.');
return;
}
toast.success(t('billing.cancelSuccess') || "Votre abonnement a été résilié avec succès. Il prendra fin à la fin de la période de facturation en cours.");
queryClient.invalidateQueries({ queryKey: ['billing', 'status'] });
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
} catch (err) {
console.error('[BillingPlans] cancel error:', err);
toast.error('Failed to cancel subscription.');
} finally {
setCancelLoading(false);
}
};
const handleCheckoutComplete = useCallback(() => {
setIsCheckoutOpen(false);
setCheckoutClientSecret(null);
@@ -310,38 +346,42 @@ export function BillingPlans() {
{effectiveTier === 'BASIC' ? t('billing.freePlan') : effectiveTier === 'PRO' ? t('billing.proPlan') : effectiveTier === 'BUSINESS' ? t('billing.businessPlan') : t('billing.enterprisePlan')}
</h4>
</div>
<div className="ml-auto">
<span className={cn(
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest',
status?.status === 'active' || status?.status === 'ACTIVE'
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20'
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20'
)}>
{status?.status ? t(`billing.${status.status.toLowerCase()}`) || status.status : t('billing.active')}
</span>
</div>
{isPaid && (
<div className="ml-auto">
<span className={cn(
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest',
status?.status === 'active' || status?.status === 'ACTIVE'
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20'
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20'
)}>
{status?.status ? t(`billing.${status.status.toLowerCase()}`) || status.status : t('billing.active')}
</span>
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4 border-t border-border/40">
<div className="space-y-1">
<span className="text-[10px] text-concrete uppercase tracking-wider">{t('billing.billingPeriod')}</span>
<p className="text-xs font-semibold text-ink">
{status?.currentPeriodStart && status?.currentPeriodEnd ? (
`${formatDate(status.currentPeriodStart)} ${formatDate(status.currentPeriodEnd)}`
) : (
'—'
)}
</p>
{isPaid && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4 border-t border-border/40">
<div className="space-y-1">
<span className="text-[10px] text-concrete uppercase tracking-wider">{t('billing.billingPeriod')}</span>
<p className="text-xs font-semibold text-ink">
{status?.currentPeriodStart && status?.currentPeriodEnd ? (
`${formatDate(status.currentPeriodStart)} ${formatDate(status.currentPeriodEnd)}`
) : (
'—'
)}
</p>
</div>
<div className="space-y-1">
<span className="text-[10px] text-concrete uppercase tracking-wider">
{status?.cancelAtPeriodEnd ? t('billing.expiresOn') : t('billing.nextBillingDate')}
</span>
<p className="text-xs font-semibold text-ink">
{status?.currentPeriodEnd ? formatDate(status.currentPeriodEnd) : '—'}
</p>
</div>
</div>
<div className="space-y-1">
<span className="text-[10px] text-concrete uppercase tracking-wider">
{status?.cancelAtPeriodEnd ? t('billing.expiresOn') : t('billing.nextBillingDate')}
</span>
<p className="text-xs font-semibold text-ink">
{status?.currentPeriodEnd ? formatDate(status.currentPeriodEnd) : '—'}
</p>
</div>
</div>
)}
{isPaid && (
<div className="flex flex-wrap gap-3">
@@ -355,13 +395,14 @@ export function BillingPlans() {
{t('billing.manageBilling') || 'Gérer la facturation'}
</button>
{status?.hasStripeSubscription && (
{status?.hasStripeSubscription && !status?.cancelAtPeriodEnd && (
<button
type="button"
onClick={handlePortal}
disabled={portalLoading}
onClick={handleCancelSubscription}
disabled={cancelLoading}
className="flex items-center gap-2 px-5 py-2.5 border border-rose-200 text-rose-600 dark:border-rose-800/40 dark:text-rose-400 hover:bg-rose-50/50 dark:hover:bg-rose-950/15 rounded-xl text-xs font-semibold transition-all"
>
{cancelLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{t('billing.cancelSubscription') || "Résilier l'abonnement"}
</button>
)}