[PULSE-35] Billing tab: cancel and change-plan UI and copy #19

Merged
uz1mani merged 8 commits from staging into main 2026-02-09 14:16:21 +00:00
2 changed files with 123 additions and 1 deletions
Showing only changes of commit d39f9231c0 - Show all commits

View File

@@ -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 {
greptile-apps[bot] commented 2026-02-09 13:48:47 +00:00 (Migrated from github.com)
Review

Incorrect subscription gating
hasActiveSubscription is currently defined as has_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 through createCheckoutSession (components/settings/OrganizationSettings.tsx:303-307) instead of using changePlan, 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” on subscription_status alone (or on presence of a subscription id) rather than has_payment_method.

Prompt To Fix With AI
This is a comment left during a code review.
Path: components/settings/OrganizationSettings.tsx
Line: 291:291

Comment:
**Incorrect subscription gating**
`hasActiveSubscription` is currently defined as `has_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 through `createCheckoutSession` (components/settings/OrganizationSettings.tsx:303-307) instead of using `changePlan`, 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” on `subscription_status` alone (or on presence of a subscription id) rather than `has_payment_method`.

How can I resolve this? If you propose a fix, please make it concise.
**Incorrect subscription gating** `hasActiveSubscription` is currently defined as `has_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 through `createCheckoutSession` (components/settings/OrganizationSettings.tsx:303-307) instead of using `changePlan`, 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” on `subscription_status` alone (or on presence of a subscription id) rather than `has_payment_method`. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: components/settings/OrganizationSettings.tsx Line: 291:291 Comment: **Incorrect subscription gating** `hasActiveSubscription` is currently defined as `has_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 through `createCheckoutSession` (components/settings/OrganizationSettings.tsx:303-307) instead of using `changePlan`, 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” on `subscription_status` alone (or on presence of a subscription id) rather than `has_payment_method`. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-09 13:52:10 +00:00 (Migrated from github.com)
Review

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).

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).
await cancelSubscription({ at_period_end: atPeriodEnd })
toast.success(atPeriodEnd ? 'Subscription will cancel at the end of the billing period.' : 'Subscription canceled.')
setShowCancelPrompt(false)
loadSubscription()
greptile-apps[bot] commented 2026-02-09 14:00:49 +00:00 (Migrated from github.com)
Review

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.

Prompt To Fix With AI
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.
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`. <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>
uz1mani commented 2026-02-09 14:06:33 +00:00 (Migrated from github.com)
Review

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.

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
greptile-apps[bot] commented 2026-02-09 13:54:45 +00:00 (Migrated from github.com)
Review

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.

Prompt To Fix With AI
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.
**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>
uz1mani commented 2026-02-09 13:57:41 +00:00 (Migrated from github.com)
Review

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.

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>
)}
</>
greptile-apps[bot] commented 2026-02-09 14:00:51 +00:00 (Migrated from github.com)
Review

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.

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 AI
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.
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> <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>
uz1mani commented 2026-02-09 14:06:24 +00:00 (Migrated from github.com)
Review

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>
)
}

View File

@@ -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