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:
@@ -135,11 +135,11 @@ export default function AdminOrgDetailPage() {
|
||||
{org.current_period_end ? formatDateTime(new Date(org.current_period_end)) : '-'}
|
||||
</span>
|
||||
|
||||
<span className="text-neutral-500">Stripe Cust:</span>
|
||||
<span className="font-mono text-xs">{org.stripe_customer_id || '-'}</span>
|
||||
<span className="text-neutral-500">Customer ID:</span>
|
||||
<span className="font-mono text-xs">{org.billing_customer_id || '-'}</span>
|
||||
|
||||
<span className="text-neutral-500">Stripe Sub:</span>
|
||||
<span className="font-mono text-xs">{org.stripe_subscription_id || '-'}</span>
|
||||
<span className="text-neutral-500">Subscription ID:</span>
|
||||
<span className="font-mono text-xs">{org.billing_subscription_id || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
17
app/page.tsx
17
app/page.tsx
@@ -334,7 +334,7 @@ export default function HomePage() {
|
||||
return `${label} Plan`
|
||||
})()}
|
||||
</p>
|
||||
{(typeof subscription.sites_count === 'number' || (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number') || (subscription.next_invoice_amount_due != null && subscription.next_invoice_currency && !subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing'))) && (
|
||||
{(typeof subscription.sites_count === 'number' || (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number') || (!subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing'))) && (
|
||||
<p className="text-sm text-neutral-400 mt-1">
|
||||
{typeof subscription.sites_count === 'number' && (
|
||||
<span>Sites: {(() => {
|
||||
@@ -346,20 +346,9 @@ export default function HomePage() {
|
||||
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && (
|
||||
<span>Pageviews: {subscription.pageview_usage.toLocaleString()}/{subscription.pageview_limit.toLocaleString()}</span>
|
||||
)}
|
||||
{subscription.next_invoice_amount_due != null && subscription.next_invoice_currency && !subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing') && (
|
||||
{!subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing') && subscription.current_period_end && (
|
||||
<span className="block mt-1">
|
||||
Renews {(() => {
|
||||
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
|
||||
? formatDate(d)
|
||||
: null
|
||||
const amount = (subscription.next_invoice_amount_due / 100).toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: subscription.next_invoice_currency.toUpperCase(),
|
||||
})
|
||||
return dateStr ? `${dateStr} for ${amount}` : amount
|
||||
})()}
|
||||
Renews {formatDate(new Date(subscription.current_period_end))}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -2,8 +2,8 @@ import { authFetch } from './client'
|
||||
|
||||
export interface AdminOrgSummary {
|
||||
organization_id: string
|
||||
stripe_customer_id: string
|
||||
stripe_subscription_id: string
|
||||
billing_customer_id: string
|
||||
billing_subscription_id: string
|
||||
plan_id: string
|
||||
billing_interval: string
|
||||
pageview_limit: number
|
||||
|
||||
@@ -19,16 +19,10 @@ export interface SubscriptionDetails {
|
||||
sites_count?: number
|
||||
/** Pageviews in current billing period (when pageview_limit > 0). Present when backend supports usage API. */
|
||||
pageview_usage?: number
|
||||
/** Business name from Stripe Tax ID collection / business purchase flow (optional). */
|
||||
/** Business name from billing (optional). */
|
||||
business_name?: string
|
||||
/** Tax IDs collected on the Stripe customer (VAT, EIN, etc.) for invoice verification. */
|
||||
tax_ids?: TaxID[]
|
||||
/** Next invoice amount in cents (for "Renews on X for €Y" display). */
|
||||
next_invoice_amount_due?: number
|
||||
/** Currency for next invoice (e.g. eur). */
|
||||
next_invoice_currency?: string
|
||||
/** Unix timestamp when next invoice period ends. */
|
||||
next_invoice_period_end?: number
|
||||
/** Tax ID collected on the billing customer (VAT, EIN, etc.). */
|
||||
tax_id?: TaxID | null
|
||||
}
|
||||
|
||||
export async function getSubscription(): Promise<SubscriptionDetails> {
|
||||
@@ -66,22 +60,6 @@ export interface ChangePlanParams {
|
||||
limit: number
|
||||
}
|
||||
|
||||
export interface PreviewInvoiceResult {
|
||||
amount_due: number
|
||||
currency: string
|
||||
period_end: number
|
||||
}
|
||||
|
||||
export async function previewInvoice(params: ChangePlanParams): Promise<PreviewInvoiceResult | null> {
|
||||
const res = await apiRequest<PreviewInvoiceResult | Record<string, never>>('/api/billing/preview-invoice', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
if (res && typeof res === 'object' && 'amount_due' in res && typeof (res as PreviewInvoiceResult).amount_due === 'number') {
|
||||
return res as PreviewInvoiceResult
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function changePlan(params: ChangePlanParams): Promise<{ ok: boolean }> {
|
||||
return apiRequest<{ ok: boolean }>('/api/billing/change-plan', {
|
||||
@@ -103,17 +81,18 @@ export async function createCheckoutSession(params: CreateCheckoutParams): Promi
|
||||
})
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
export interface Order {
|
||||
id: string
|
||||
amount_paid: number
|
||||
amount_due: number
|
||||
total_amount: number
|
||||
subtotal_amount: number
|
||||
tax_amount: number
|
||||
currency: string
|
||||
status: string
|
||||
created: number
|
||||
hosted_invoice_url: string
|
||||
invoice_pdf: string
|
||||
created_at: string
|
||||
paid: boolean
|
||||
invoice_number: string
|
||||
}
|
||||
|
||||
export async function getInvoices(): Promise<Invoice[]> {
|
||||
return apiRequest<Invoice[]>('/api/billing/invoices')
|
||||
export async function getOrders(): Promise<Order[]> {
|
||||
return apiRequest<Order[]>('/api/billing/invoices')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user