fix: enhance billing operations and session management in API
This commit is contained in:
@@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
- **More reliable service health reporting.** The backend health check no longer falsely reports the service as unhealthy after sustained traffic. Previously, an internal counter grew over time and would eventually cross a fixed threshold — even under normal load — causing orchestrators to unnecessarily restart the service.
|
- **More reliable service health reporting.** The backend health check no longer falsely reports the service as unhealthy after sustained traffic. Previously, an internal counter grew over time and would eventually cross a fixed threshold — even under normal load — causing orchestrators to unnecessarily restart the service.
|
||||||
- **Lower resource usage under load.** The backend now uses a single shared connection to Redis instead of opening dozens of separate ones. Previously, each rate limiter and internal component created its own connection pool, which could waste resources and risk hitting connection limits during heavy traffic.
|
- **Lower resource usage under load.** The backend now uses a single shared connection to Redis instead of opening dozens of separate ones. Previously, each rate limiter and internal component created its own connection pool, which could waste resources and risk hitting connection limits during heavy traffic.
|
||||||
|
- **More reliable billing operations.** Billing actions like changing your plan, cancelling, and viewing invoices now benefit from the same automatic session refresh, request tracing, and error handling as the rest of the app. Previously, these used a separate internal path that could fail silently if your session expired mid-action.
|
||||||
|
- **Session list now correctly highlights your current session.** The active sessions list in settings now properly identifies which session you're currently using. Previously, the "current session" marker never appeared due to an internal mismatch in how sessions were identified.
|
||||||
|
|
||||||
## [0.12.0-alpha] - 2026-03-01
|
## [0.12.0-alpha] - 2026-03-01
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { API_URL } from './client'
|
import apiRequest from './client'
|
||||||
|
|
||||||
export interface TaxID {
|
export interface TaxID {
|
||||||
type: string
|
type: string
|
||||||
@@ -31,39 +31,12 @@ export interface SubscriptionDetails {
|
|||||||
next_invoice_period_end?: number
|
next_invoice_period_end?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
async function billingFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
||||||
const url = `${API_URL}${endpoint}`
|
|
||||||
|
|
||||||
const headers: HeadersInit = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options.headers,
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
...options,
|
|
||||||
headers,
|
|
||||||
credentials: 'include', // Send cookies
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorBody = await response.json().catch(() => ({
|
|
||||||
error: 'Unknown error',
|
|
||||||
message: `HTTP ${response.status}: ${response.statusText}`,
|
|
||||||
}))
|
|
||||||
throw new Error(errorBody.message || errorBody.error || 'Request failed')
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSubscription(): Promise<SubscriptionDetails> {
|
export async function getSubscription(): Promise<SubscriptionDetails> {
|
||||||
return await billingFetch<SubscriptionDetails>('/api/billing/subscription', {
|
return apiRequest<SubscriptionDetails>('/api/billing/subscription')
|
||||||
method: 'GET',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPortalSession(): Promise<{ url: string }> {
|
export async function createPortalSession(): Promise<{ url: string }> {
|
||||||
return await billingFetch<{ url: string }>('/api/billing/portal', {
|
return apiRequest<{ url: string }>('/api/billing/portal', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -74,7 +47,7 @@ export interface CancelSubscriptionParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelSubscription(params?: CancelSubscriptionParams): Promise<{ ok: boolean; 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', {
|
return apiRequest<{ ok: boolean; at_period_end: boolean }>('/api/billing/cancel', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ at_period_end: params?.at_period_end ?? true }),
|
body: JSON.stringify({ at_period_end: params?.at_period_end ?? true }),
|
||||||
})
|
})
|
||||||
@@ -82,7 +55,7 @@ export async function cancelSubscription(params?: CancelSubscriptionParams): Pro
|
|||||||
|
|
||||||
/** Clears cancel_at_period_end so the subscription continues past the current period. */
|
/** Clears cancel_at_period_end so the subscription continues past the current period. */
|
||||||
export async function resumeSubscription(): Promise<{ ok: boolean }> {
|
export async function resumeSubscription(): Promise<{ ok: boolean }> {
|
||||||
return await billingFetch<{ ok: boolean }>('/api/billing/resume', {
|
return apiRequest<{ ok: boolean }>('/api/billing/resume', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -100,7 +73,7 @@ export interface PreviewInvoiceResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function previewInvoice(params: ChangePlanParams): Promise<PreviewInvoiceResult | null> {
|
export async function previewInvoice(params: ChangePlanParams): Promise<PreviewInvoiceResult | null> {
|
||||||
const res = await billingFetch<PreviewInvoiceResult | Record<string, never>>('/api/billing/preview-invoice', {
|
const res = await apiRequest<PreviewInvoiceResult | Record<string, never>>('/api/billing/preview-invoice', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(params),
|
body: JSON.stringify(params),
|
||||||
})
|
})
|
||||||
@@ -111,7 +84,7 @@ export async function previewInvoice(params: ChangePlanParams): Promise<PreviewI
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 apiRequest<{ ok: boolean }>('/api/billing/change-plan', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(params),
|
body: JSON.stringify(params),
|
||||||
})
|
})
|
||||||
@@ -124,7 +97,7 @@ export interface CreateCheckoutParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createCheckoutSession(params: CreateCheckoutParams): Promise<{ url: string }> {
|
export async function createCheckoutSession(params: CreateCheckoutParams): Promise<{ url: string }> {
|
||||||
return await billingFetch<{ url: string }>('/api/billing/checkout', {
|
return apiRequest<{ url: string }>('/api/billing/checkout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(params),
|
body: JSON.stringify(params),
|
||||||
})
|
})
|
||||||
@@ -142,7 +115,5 @@ export interface Invoice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getInvoices(): Promise<Invoice[]> {
|
export async function getInvoices(): Promise<Invoice[]> {
|
||||||
return await billingFetch<Invoice[]>('/api/billing/invoices', {
|
return apiRequest<Invoice[]>('/api/billing/invoices')
|
||||||
method: 'GET',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,24 +18,8 @@ export interface Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserSessions(): Promise<{ sessions: Session[] }> {
|
export async function getUserSessions(): Promise<{ sessions: Session[] }> {
|
||||||
// Hash the current refresh token to identify current session
|
// Current session is identified server-side via the httpOnly refresh token cookie
|
||||||
const refreshToken = typeof window !== 'undefined' ? localStorage.getItem('refreshToken') : null
|
return apiRequest<{ sessions: Session[] }>('/auth/user/sessions')
|
||||||
let currentTokenHash = ''
|
|
||||||
|
|
||||||
if (refreshToken) {
|
|
||||||
// Hash the refresh token using SHA-256
|
|
||||||
const encoder = new TextEncoder()
|
|
||||||
const data = encoder.encode(refreshToken)
|
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
|
||||||
currentTokenHash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiRequest<{ sessions: Session[] }>('/auth/user/sessions', {
|
|
||||||
headers: currentTokenHash ? {
|
|
||||||
'X-Current-Session-Hash': currentTokenHash,
|
|
||||||
} : undefined,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revokeSession(sessionId: string): Promise<void> {
|
export async function revokeSession(sessionId: string): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user