[PULSE-35] Billing tab: cancel and change-plan UI and copy #19
@@ -16,7 +16,7 @@ import {
|
||||
OrganizationInvitation,
|
||||
Organization
|
||||
} from '@/lib/api/organization'
|
||||
import { getSubscription, createPortalSession, getInvoices, SubscriptionDetails, Invoice } from '@/lib/api/billing'
|
||||
import { getSubscription, createPortalSession, getInvoices, cancelSubscription, SubscriptionDetails, Invoice } from '@/lib/api/billing'
|
||||
import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit'
|
||||
import { toast } from '@ciphera-net/ui'
|
||||
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
||||
@@ -68,6 +68,8 @@ export default function OrganizationSettings() {
|
||||
const [subscription, setSubscription] = useState<SubscriptionDetails | null>(null)
|
||||
const [isLoadingSubscription, setIsLoadingSubscription] = useState(false)
|
||||
const [isRedirectingToPortal, setIsRedirectingToPortal] = useState(false)
|
||||
const [isCanceling, setIsCanceling] = useState(false)
|
||||
const [showCancelPrompt, setShowCancelPrompt] = useState(false)
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
const [isLoadingInvoices, setIsLoadingInvoices] = useState(false)
|
||||
|
||||
@@ -257,6 +259,20 @@ export default function OrganizationSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelSubscription = async (atPeriodEnd: boolean) => {
|
||||
setIsCanceling(true)
|
||||
try {
|
||||
|
|
||||
await cancelSubscription({ at_period_end: atPeriodEnd })
|
||||
toast.success(atPeriodEnd ? 'Subscription will cancel at the end of the billing period.' : 'Subscription canceled.')
|
||||
setShowCancelPrompt(false)
|
||||
loadSubscription()
|
||||
|
Opening modal with stale tier selection if Prompt To Fix With AIOpening modal with stale tier selection if `pageview_limit` is 0. When `subscription?.pageview_limit` is 0 or falsy (not just undefined), falls back to tier index 2 (100k). Consider checking for non-zero explicitly: `subscription?.pageview_limit > 0`.
<details><summary>Prompt To Fix With AI</summary>
`````markdown
This is a comment left during a code review.
Path: components/settings/OrganizationSettings.tsx
Line: 281:288
Comment:
Opening modal with stale tier selection if `pageview_limit` is 0. When `subscription?.pageview_limit` is 0 or falsy (not just undefined), falls back to tier index 2 (100k). Consider checking for non-zero explicitly: `subscription?.pageview_limit > 0`.
How can I resolve this? If you propose a fix, please make it concise.
`````
</details>
Issue: The condition subscription?.pageview_limit is falsy for 0, so when the limit was 0 we fell back to tier index 2 (100k) and opened the modal with the wrong tier. Issue: The condition subscription?.pageview_limit is falsy for 0, so when the limit was 0 we fell back to tier index 2 (100k) and opened the modal with the wrong tier.
Fix: Use an explicit check: subscription?.pageview_limit != null && subscription.pageview_limit > 0 before calling getTierIndexForLimit(subscription.pageview_limit); otherwise keep the default tier index 2.
Why: A real limit of 0 should not be treated as “unknown”; only when there’s no valid positive limit should we use the default.
|
||||
} catch (error: any) {
|
||||
toast.error(getAuthErrorMessage(error) || error.message || 'Failed to cancel subscription')
|
||||
} finally {
|
||||
setIsCanceling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (deleteConfirm !== 'DELETE') return
|
||||
|
||||
@@ -754,6 +770,45 @@ export default function OrganizationSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cancel-at-period-end notice or Cancel subscription action */}
|
||||
{subscription.has_payment_method && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing') && (
|
||||
<>
|
||||
{subscription.cancel_at_period_end ? (
|
||||
<div className="p-6 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800 rounded-2xl">
|
||||
<h3 className="font-medium text-amber-800 dark:text-amber-200 mb-2">Subscription set to cancel</h3>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300 mb-1">
|
||||
Your subscription will end on{' '}
|
||||
{(() => {
|
||||
const raw = subscription.current_period_end
|
||||
const d = raw ? new Date(raw as string) : null
|
||||
return raw && d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString() : '—'
|
||||
})()}
|
||||
. You can use the app until then.
|
||||
</p>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
Your data is retained for 30 days after access ends. You can resubscribe anytime from the Stripe portal.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-800 rounded-2xl flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="font-medium text-neutral-900 dark:text-white mb-1">Cancel subscription</h3>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
After cancellation, you can use the app until the end of your billing period. Your data is retained for 30 days after access ends.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 border border-red-200 dark:border-red-800"
|
||||
onClick={() => setShowCancelPrompt(true)}
|
||||
>
|
||||
Cancel subscription
|
||||
|
Invalid JSX mapping The invoices branch renders Prompt To Fix With AI**Invalid JSX mapping**
The invoices branch renders `invoices.map(...)` as a bare token inside JSX. This needs to be wrapped in curly braces (or wrapped in a fragment) to compile/render.
<details><summary>Prompt To Fix With AI</summary>
`````markdown
This is a comment left during a code review.
Path: components/settings/OrganizationSettings.tsx
Line: 867:870
Comment:
**Invalid JSX mapping**
The invoices branch renders `invoices.map(...)` as a bare token inside JSX. This needs to be wrapped in curly braces (or wrapped in a fragment) to compile/render.
How can I resolve this? If you propose a fix, please make it concise.
`````
</details>
Issue: The third branch of the ternary was invoices.map(...) without being wrapped in a JS expression. In JSX, that can be treated as a bare token instead of an expression, so the map result may not render correctly. Issue: The third branch of the ternary was invoices.map(...) without being wrapped in a JS expression. In JSX, that can be treated as a bare token instead of an expression, so the map result may not render correctly.
Fix: Wrapped the map in curly braces so the third branch is { invoices.map((invoice) => ( ... )) }, and adjusted the closing to ))} so the block is valid JSX.
Why: In JSX, only expressions inside { } are evaluated; wrapping the map in braces makes it a proper expression and keeps behavior consistent across tools and React versions.
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
Hardcoded Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! Prompt To Fix With AIHardcoded `'en-US'` locale for currency formatting. This forces US-style formatting (e.g., $1,234.56) regardless of user's locale. Pass `undefined` or the user's locale to respect their regional formatting preferences.
<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>
<details><summary>Prompt To Fix With AI</summary>
`````markdown
This is a comment left during a code review.
Path: components/settings/OrganizationSettings.tsx
Line: 875:875
Comment:
Hardcoded `'en-US'` locale for currency formatting. This forces US-style formatting (e.g., $1,234.56) regardless of user's locale. Pass `undefined` or the user's locale to respect their regional formatting preferences.
<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>
How can I resolve this? If you propose a fix, please make it concise.
`````
</details>
No change: keeping the hardcoded 'en-US' locale for currency formatting. No change: keeping the hardcoded 'en-US' locale for currency formatting.
|
||||
)}
|
||||
|
||||
{!subscription.has_payment_method && (
|
||||
<div className="p-6 bg-brand-orange/5 border border-brand-orange/20 rounded-2xl">
|
||||
<h3 className="font-medium text-brand-orange mb-2">Upgrade to Pro</h3>
|
||||
@@ -1053,6 +1108,59 @@ export default function OrganizationSettings() {
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Cancel subscription confirmation modal */}
|
||||
<AnimatePresence>
|
||||
{showCancelPrompt && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
className="bg-white dark:bg-neutral-900 rounded-2xl shadow-2xl max-w-md w-full p-6 border border-neutral-200 dark:border-neutral-800"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Cancel subscription?</h3>
|
||||
<button
|
||||
onClick={() => setShowCancelPrompt(false)}
|
||||
className="text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-400"
|
||||
disabled={isCanceling}
|
||||
>
|
||||
<XIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-neutral-600 dark:text-neutral-300 mb-4">
|
||||
You can cancel at the end of your billing period (you keep access until then) or cancel immediately. Your data is retained for 30 days after access ends.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={() => handleCancelSubscription(true)}
|
||||
disabled={isCanceling}
|
||||
isLoading={isCanceling}
|
||||
>
|
||||
Cancel at period end
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
onClick={() => handleCancelSubscription(false)}
|
||||
disabled={isCanceling}
|
||||
>
|
||||
Cancel immediately
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setShowCancelPrompt(false)} disabled={isCanceling}>
|
||||
Keep subscription
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface SubscriptionDetails {
|
||||
billing_interval: string
|
||||
pageview_limit: number
|
||||
has_payment_method: boolean
|
||||
/** True when subscription is set to cancel at the end of the current period. */
|
||||
cancel_at_period_end?: boolean
|
||||
/** Number of sites for the org (billing usage). Present when backend supports usage API. */
|
||||
sites_count?: number
|
||||
/** Pageviews in current billing period (when pageview_limit > 0). Present when backend supports usage API. */
|
||||
@@ -50,6 +52,18 @@ export async function createPortalSession(): Promise<{ url: string }> {
|
||||
})
|
||||
}
|
||||
|
||||
export interface CancelSubscriptionParams {
|
||||
/** If true (default), cancel at end of billing period; if false, cancel immediately. */
|
||||
at_period_end?: boolean
|
||||
}
|
||||
|
||||
export async function cancelSubscription(params?: CancelSubscriptionParams): Promise<{ ok: boolean; at_period_end: boolean }> {
|
||||
return await billingFetch<{ ok: boolean; at_period_end: boolean }>('/api/billing/cancel', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ at_period_end: params?.at_period_end ?? true }),
|
||||
})
|
||||
}
|
||||
|
||||
export interface CreateCheckoutParams {
|
||||
plan_id: string
|
||||
interval: string
|
||||
|
||||
Incorrect subscription gating
hasActiveSubscriptionis currently defined ashas_payment_method && (active || trialing)(components/settings/OrganizationSettings.tsx:291), which will treat a trialing subscription with no payment method as not active. In that case the Change plan modal will incorrectly route users throughcreateCheckoutSession(components/settings/OrganizationSettings.tsx:303-307) instead of usingchangePlan, and the modal copy will say “You’ll start a new subscription.” This breaks the stated trial UX for orgs that are trialing before adding a card. Consider basing “existing subscription” onsubscription_statusalone (or on presence of a subscription id) rather thanhas_payment_method.Prompt To Fix With AI
Issue: hasActiveSubscription required has_payment_method, so trialing orgs without a card were treated as having no subscription. They were sent to checkout instead of using the in-app change-plan flow.
Fix: hasActiveSubscription is now based only on subscription status:
subscription?.subscription_status === 'active' || subscription?.subscription_status === 'trialing'. No dependency on has_payment_method.
Why: Trials can exist before a payment method is added. Gating on status (or Stripe subscription id) matches that and keeps trialing users in the correct flow (change plan vs new checkout).