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:
@@ -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 you’re 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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user