feat: add invoice preview functionality in OrganizationSettings to enhance user experience with upcoming billing details
This commit is contained in:
@@ -16,7 +16,7 @@ import {
|
|||||||
OrganizationInvitation,
|
OrganizationInvitation,
|
||||||
Organization
|
Organization
|
||||||
} from '@/lib/api/organization'
|
} from '@/lib/api/organization'
|
||||||
import { getSubscription, createPortalSession, getInvoices, cancelSubscription, resumeSubscription, changePlan, createCheckoutSession, SubscriptionDetails, Invoice } from '@/lib/api/billing'
|
import { getSubscription, createPortalSession, getInvoices, cancelSubscription, resumeSubscription, changePlan, previewInvoice, createCheckoutSession, SubscriptionDetails, Invoice, PreviewInvoiceResult } from '@/lib/api/billing'
|
||||||
import { TRAFFIC_TIERS, PLAN_ID_SOLO, getTierIndexForLimit, getLimitForTierIndex, getSitesLimitForPlan } from '@/lib/plans'
|
import { TRAFFIC_TIERS, PLAN_ID_SOLO, getTierIndexForLimit, getLimitForTierIndex, getSitesLimitForPlan } from '@/lib/plans'
|
||||||
import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit'
|
import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit'
|
||||||
import { getNotificationSettings, updateNotificationSettings } from '@/lib/api/notification-settings'
|
import { getNotificationSettings, updateNotificationSettings } from '@/lib/api/notification-settings'
|
||||||
@@ -87,6 +87,8 @@ export default function OrganizationSettings() {
|
|||||||
const [showChangePlanModal, setShowChangePlanModal] = useState(false)
|
const [showChangePlanModal, setShowChangePlanModal] = useState(false)
|
||||||
const [changePlanTierIndex, setChangePlanTierIndex] = useState(2)
|
const [changePlanTierIndex, setChangePlanTierIndex] = useState(2)
|
||||||
const [changePlanYearly, setChangePlanYearly] = useState(false)
|
const [changePlanYearly, setChangePlanYearly] = useState(false)
|
||||||
|
const [invoicePreview, setInvoicePreview] = useState<PreviewInvoiceResult | null>(null)
|
||||||
|
const [isLoadingPreview, setIsLoadingPreview] = useState(false)
|
||||||
const [isChangingPlan, setIsChangingPlan] = useState(false)
|
const [isChangingPlan, setIsChangingPlan] = useState(false)
|
||||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||||
const [isLoadingInvoices, setIsLoadingInvoices] = useState(false)
|
const [isLoadingInvoices, setIsLoadingInvoices] = useState(false)
|
||||||
@@ -349,11 +351,27 @@ export default function OrganizationSettings() {
|
|||||||
setChangePlanTierIndex(2)
|
setChangePlanTierIndex(2)
|
||||||
}
|
}
|
||||||
setChangePlanYearly(subscription?.billing_interval === 'year')
|
setChangePlanYearly(subscription?.billing_interval === 'year')
|
||||||
|
setInvoicePreview(null)
|
||||||
setShowChangePlanModal(true)
|
setShowChangePlanModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasActiveSubscription = subscription?.subscription_status === 'active' || subscription?.subscription_status === 'trialing'
|
const hasActiveSubscription = subscription?.subscription_status === 'active' || subscription?.subscription_status === 'trialing'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showChangePlanModal || !hasActiveSubscription) {
|
||||||
|
setInvoicePreview(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
setIsLoadingPreview(true)
|
||||||
|
const interval = changePlanYearly ? 'year' : 'month'
|
||||||
|
const limit = getLimitForTierIndex(changePlanTierIndex)
|
||||||
|
previewInvoice({ plan_id: PLAN_ID_SOLO, interval, limit })
|
||||||
|
.then((res) => { if (!cancelled) setInvoicePreview(res ?? null) })
|
||||||
|
.finally(() => { if (!cancelled) setIsLoadingPreview(false) })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [showChangePlanModal, hasActiveSubscription, changePlanTierIndex, changePlanYearly])
|
||||||
|
|
||||||
const handleChangePlanSubmit = async () => {
|
const handleChangePlanSubmit = async () => {
|
||||||
const interval = changePlanYearly ? 'year' : 'month'
|
const interval = changePlanYearly ? 'year' : 'month'
|
||||||
const limit = getLimitForTierIndex(changePlanTierIndex)
|
const limit = getLimitForTierIndex(changePlanTierIndex)
|
||||||
@@ -925,8 +943,18 @@ export default function OrganizationSettings() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-semibold text-neutral-900 dark:text-white">
|
<div className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||||
{(() => {
|
{(() => {
|
||||||
const d = subscription.current_period_end ? new Date(subscription.current_period_end as string) : null
|
const ts = subscription.next_invoice_period_end ?? subscription.current_period_end
|
||||||
return d && !Number.isNaN(d.getTime()) && d.getTime() !== 0 ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—'
|
const d = ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null
|
||||||
|
const dateStr = d && !Number.isNaN(d.getTime()) && d.getTime() !== 0
|
||||||
|
? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
: '—'
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -1426,6 +1454,26 @@ export default function OrganizationSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{hasActiveSubscription && (
|
||||||
|
<div className="mt-4 p-3 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 {new Date(invoicePreview.period_end * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}{' '}
|
||||||
|
<span className="text-neutral-500">(prorated)</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex gap-2 mt-6">
|
<div className="flex gap-2 mt-6">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleChangePlanSubmit}
|
onClick={handleChangePlanSubmit}
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ export interface SubscriptionDetails {
|
|||||||
business_name?: string
|
business_name?: string
|
||||||
/** Tax IDs collected on the Stripe customer (VAT, EIN, etc.) for invoice verification. */
|
/** Tax IDs collected on the Stripe customer (VAT, EIN, etc.) for invoice verification. */
|
||||||
tax_ids?: TaxID[]
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
async function billingFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
async function billingFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||||
@@ -87,6 +93,23 @@ export interface ChangePlanParams {
|
|||||||
limit: number
|
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 billingFetch<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 }> {
|
export async function changePlan(params: ChangePlanParams): Promise<{ ok: boolean }> {
|
||||||
return await billingFetch<{ ok: boolean }>('/api/billing/change-plan', {
|
return await billingFetch<{ ok: boolean }>('/api/billing/change-plan', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user