feat(billing): update frontend for polar migration

Update billing types, remove invoice preview, replace Stripe invoice
display with Polar orders, update tax ID from array to single object,
remove upcoming invoice amount display.
This commit is contained in:
Usman Baig
2026-03-23 16:36:54 +01:00
parent cd347ea072
commit eca21bf627
6 changed files with 54 additions and 148 deletions

View File

@@ -109,7 +109,7 @@ export default function PricingSection() {
const [loadingPlan, setLoadingPlan] = useState<string | null>(null)
const { user } = useAuth()
// * Show toast when redirected from Stripe Checkout with canceled=true
// * Show toast when redirected from Polar Checkout with canceled=true
useEffect(() => {
if (searchParams.get('canceled') === 'true') {
toast.info('Checkout was canceled. You can try again whenever youre ready.')
@@ -196,7 +196,7 @@ export default function PricingSection() {
limit,
})
// 3. Redirect to Stripe Checkout
// 3. Redirect to Polar Checkout
if (url) {
window.location.href = url
} else {

View File

@@ -19,7 +19,7 @@ import {
OrganizationInvitation,
Organization
} from '@/lib/api/organization'
import { getSubscription, createPortalSession, getInvoices, cancelSubscription, resumeSubscription, changePlan, previewInvoice, createCheckoutSession, SubscriptionDetails, Invoice, PreviewInvoiceResult } from '@/lib/api/billing'
import { getSubscription, createPortalSession, getOrders, cancelSubscription, resumeSubscription, changePlan, createCheckoutSession, SubscriptionDetails, Order } from '@/lib/api/billing'
import { TRAFFIC_TIERS, PLAN_ID_SOLO, PLAN_ID_TEAM, PLAN_ID_BUSINESS, getTierIndexForLimit, getLimitForTierIndex, getSitesLimitForPlan } from '@/lib/plans'
import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit'
import { getNotificationSettings, updateNotificationSettings } from '@/lib/api/notification-settings'
@@ -36,7 +36,6 @@ import {
XIcon,
Captcha,
BookOpenIcon,
DownloadIcon,
ExternalLinkIcon,
LayoutDashboardIcon,
Spinner,
@@ -93,10 +92,8 @@ export default function OrganizationSettings() {
const [changePlanId, setChangePlanId] = useState<string>(PLAN_ID_SOLO)
const [changePlanTierIndex, setChangePlanTierIndex] = useState(2)
const [changePlanYearly, setChangePlanYearly] = useState(false)
const [invoicePreview, setInvoicePreview] = useState<PreviewInvoiceResult | null>(null)
const [isLoadingPreview, setIsLoadingPreview] = useState(false)
const [isChangingPlan, setIsChangingPlan] = useState(false)
const [invoices, setInvoices] = useState<Invoice[]>([])
const [orders, setOrders] = useState<Order[]>([])
const [isLoadingInvoices, setIsLoadingInvoices] = useState(false)
// Invite State
@@ -195,14 +192,14 @@ export default function OrganizationSettings() {
}
}, [currentOrgId])
const loadInvoices = useCallback(async () => {
const loadOrders = useCallback(async () => {
if (!currentOrgId) return
setIsLoadingInvoices(true)
try {
const invs = await getInvoices()
setInvoices(invs)
const ords = await getOrders()
setOrders(ords)
} catch (error) {
logger.error('Failed to load invoices:', error)
logger.error('Failed to load orders:', error)
} finally {
setIsLoadingInvoices(false)
}
@@ -231,9 +228,9 @@ export default function OrganizationSettings() {
useEffect(() => {
if (activeTab === 'billing' && currentOrgId) {
loadSubscription()
loadInvoices()
loadOrders()
}
}, [activeTab, currentOrgId, loadSubscription, loadInvoices])
}, [activeTab, currentOrgId, loadSubscription, loadOrders])
const loadAudit = useCallback(async () => {
if (!currentOrgId) return
@@ -307,19 +304,8 @@ export default function OrganizationSettings() {
useEffect(() => {
if (!showChangePlanModal || !hasActiveSubscription) {
setInvoicePreview(null)
return
}
let cancelled = false
setIsLoadingPreview(true)
setInvoicePreview(null)
const interval = changePlanYearly ? 'year' : 'month'
const limit = getLimitForTierIndex(changePlanTierIndex)
previewInvoice({ plan_id: changePlanId, interval, limit })
.then((res) => { if (!cancelled) setInvoicePreview(res ?? null) })
.catch(() => { if (!cancelled) { setInvoicePreview(null) } })
.finally(() => { if (!cancelled) setIsLoadingPreview(false) })
return () => { cancelled = true }
}, [showChangePlanModal, hasActiveSubscription, changePlanId, changePlanTierIndex, changePlanYearly])
// If no org ID, we are in personal organization context, so don't show org settings
@@ -382,7 +368,6 @@ export default function OrganizationSettings() {
setChangePlanTierIndex(2)
}
setChangePlanYearly(subscription?.billing_interval === 'year')
setInvoicePreview(null)
setShowChangePlanModal(true)
}
@@ -954,19 +939,15 @@ export default function OrganizationSettings() {
Change plan
</Button>
</div>
{(subscription.business_name || (subscription.tax_ids && subscription.tax_ids.length > 0)) && (
{(subscription.business_name || subscription.tax_id) && (
<div className="px-6 pb-2 -mt-2 space-y-1 text-sm text-neutral-500 dark:text-neutral-400">
{subscription.business_name && (
<div>Billing for: {subscription.business_name}</div>
)}
{subscription.tax_ids && subscription.tax_ids.length > 0 && (
<div>
Tax ID{subscription.tax_ids.length > 1 ? 's' : ''}:{' '}
{subscription.tax_ids.map((t) => {
const label = t.type === 'eu_vat' ? 'VAT' : t.type === 'us_ein' ? 'EIN' : t.type.replace(/_/g, ' ').toUpperCase()
return `${label} ${t.value}${t.country ? ` (${t.country})` : ''}`
}).join(', ')}
</div>
{subscription.tax_id && (
<span>
Tax ID: {subscription.tax_id.value} ({subscription.tax_id.type})
</span>
)}
</div>
)}
@@ -1014,18 +995,11 @@ export default function OrganizationSettings() {
</div>
<div className="text-lg font-semibold text-neutral-900 dark:text-white">
{(() => {
const ts = subscription.next_invoice_period_end ?? subscription.current_period_end
const d = ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null
const dateStr = d && !Number.isNaN(d.getTime()) && d.getTime() !== 0
const ts = subscription.current_period_end
const d = ts ? new Date(ts) : null
return d && !Number.isNaN(d.getTime()) && d.getTime() !== 0
? formatDate(d)
: ''
const amount = subscription.next_invoice_amount_due != null && subscription.next_invoice_currency
? (subscription.next_invoice_amount_due / 100).toLocaleString('en-US', {
style: 'currency',
currency: subscription.next_invoice_currency.toUpperCase(),
})
: null
return amount && dateStr !== '' ? `${dateStr} for ${amount}` : dateStr
})()}
</div>
</div>
@@ -1062,57 +1036,38 @@ export default function OrganizationSettings() {
)}
</div>
{/* Invoice History */}
{/* Order History */}
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-3">Recent invoices</h3>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-3">Recent orders</h3>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
{isLoadingInvoices ? (
<InvoicesListSkeleton />
) : invoices.length === 0 ? (
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">No invoices found.</div>
) : orders.length === 0 ? (
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">No orders found.</div>
) : (
<>
{invoices.map((invoice) => (
<div key={invoice.id} className="px-4 py-3 flex items-center justify-between hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors">
{orders.map((order) => (
<div key={order.id} className="px-4 py-3 flex items-center justify-between hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors">
<div className="flex items-center gap-3">
<div>
<span className="font-medium text-sm text-neutral-900 dark:text-white">
{(invoice.amount_paid / 100).toLocaleString('en-US', { style: 'currency', currency: invoice.currency.toUpperCase() })}
{(order.total_amount / 100).toLocaleString('en-US', { style: 'currency', currency: order.currency.toUpperCase() })}
</span>
<span className="text-xs text-neutral-500 ml-2">
{formatDate(new Date(invoice.created * 1000))}
{formatDate(new Date(order.created_at))}
</span>
</div>
</div>
<div className="flex items-center gap-3">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium capitalize ${
invoice.status === 'paid'
order.status === 'paid'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
: invoice.status === 'open'
: order.status === 'open'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300'
}`}>
{invoice.status}
{order.status}
</span>
{invoice.invoice_pdf && (
<a href={invoice.invoice_pdf} target="_blank" rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-2.5 py-1.5 text-xs font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange" title="Download PDF">
<DownloadIcon className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Download</span> PDF
</a>
)}
{invoice.hosted_invoice_url && (
<a href={invoice.hosted_invoice_url} target="_blank" rel="noopener noreferrer"
className={`inline-flex items-center gap-2 px-2.5 py-1.5 text-xs font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange ${
invoice.status === 'open'
? 'bg-brand-orange text-white hover:bg-brand-orange-hover'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
title={invoice.status === 'open' ? 'Pay now' : 'View invoice'}>
<ExternalLinkIcon className="w-3.5 h-3.5" />
{invoice.status === 'open' ? 'Pay now' : <><span className="hidden sm:inline">View </span>Invoice</>}
</a>
)}
</div>
</div>
))}
@@ -1595,26 +1550,9 @@ export default function OrganizationSettings() {
</div>
{hasActiveSubscription && (
<div className="mt-4 p-4 rounded-lg bg-neutral-100 dark:bg-neutral-800/50 border border-neutral-200 dark:border-neutral-700">
{isLoadingPreview ? (
<div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
<Spinner className="w-4 h-4" />
Calculating next invoice…
</div>
) : invoicePreview ? (
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Next invoice:{' '}
{(invoicePreview.amount_due / 100).toLocaleString('en-US', {
style: 'currency',
currency: invoicePreview.currency.toUpperCase(),
})}{' '}
on {formatDate(new Date(invoicePreview.period_end * 1000))}{' '}
<span className="text-neutral-500">(prorated)</span>
</p>
) : (
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Unable to calculate preview. Your next invoice will reflect prorations.
</p>
)}
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Your plan will be updated. Any prorations will be reflected on your next invoice.
</p>
</div>
)}
<div className="flex gap-2 mt-6">