Enhance payment processing and rental features
- Updated the BTCPay service to support internal Lightning invoices with private route hints, improving payment routing for users with private channels. - Added reconciliation methods for pending rents and subscriptions to ensure missed payments are processed on startup. - Enhanced the rental and subscription services to handle payments in satoshis, aligning with Lightning Network standards. - Improved the rental modal and content detail components to display rental status and pricing more clearly, including a countdown for rental expiration. - Refactored various components to streamline user experience and ensure accurate rental access checks.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios'
|
||||
import { apiConfig } from '../config/api.config'
|
||||
import { nip98Service } from './nip98.service'
|
||||
import type { ApiError } from '../types/api'
|
||||
|
||||
/**
|
||||
@@ -26,10 +27,24 @@ class ApiService {
|
||||
* Setup request and response interceptors
|
||||
*/
|
||||
private setupInterceptors() {
|
||||
// Request interceptor - Add auth token
|
||||
// Request interceptor - Add auth token.
|
||||
// If the nip98 token is expired but a refresh token exists,
|
||||
// proactively refresh before sending to avoid unnecessary 401s.
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = this.getToken()
|
||||
async (config) => {
|
||||
let token = this.getToken()
|
||||
|
||||
// If the token appears stale (nip98 says expired but we still
|
||||
// have it in sessionStorage), try a proactive refresh
|
||||
if (token && !nip98Service.hasValidToken && sessionStorage.getItem('refresh_token')) {
|
||||
try {
|
||||
const fresh = await nip98Service.refresh()
|
||||
if (fresh) token = fresh
|
||||
} catch {
|
||||
// Non-fatal — let the request go; 401 interceptor will retry
|
||||
}
|
||||
}
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
@@ -105,7 +120,9 @@ class ApiService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh authentication token
|
||||
* Refresh authentication token.
|
||||
* Syncs both apiService (nostr_token) and nip98Service so the two
|
||||
* auth layers stay in lockstep and don't send stale tokens.
|
||||
*/
|
||||
private async refreshToken(): Promise<string> {
|
||||
// Prevent multiple simultaneous refresh requests
|
||||
@@ -125,13 +142,17 @@ class ApiService {
|
||||
refreshToken,
|
||||
})
|
||||
|
||||
const newToken = response.data.accessToken
|
||||
const { accessToken: newToken, refreshToken: newRefresh, expiresIn } = response.data
|
||||
this.setToken(newToken, 'nostr')
|
||||
|
||||
if (response.data.refreshToken) {
|
||||
sessionStorage.setItem('refresh_token', response.data.refreshToken)
|
||||
|
||||
if (newRefresh) {
|
||||
sessionStorage.setItem('refresh_token', newRefresh)
|
||||
}
|
||||
|
||||
// Keep nip98Service in sync so IndeehubApiService uses the
|
||||
// fresh token too (and its hasValidToken check is accurate).
|
||||
nip98Service.storeTokens(newToken, newRefresh ?? refreshToken, expiresIn)
|
||||
|
||||
return newToken
|
||||
} finally {
|
||||
this.tokenRefreshPromise = null
|
||||
|
||||
@@ -22,16 +22,22 @@ class IndeehubApiService {
|
||||
},
|
||||
})
|
||||
|
||||
// Attach JWT token from NIP-98 session
|
||||
this.client.interceptors.request.use((config) => {
|
||||
const token = nip98Service.accessToken
|
||||
// Attach JWT token from NIP-98 session.
|
||||
// If the token has expired but we have a refresh token, proactively
|
||||
// refresh before sending the request to avoid unnecessary 401 round-trips.
|
||||
this.client.interceptors.request.use(async (config) => {
|
||||
let token = nip98Service.accessToken
|
||||
if (!token && sessionStorage.getItem('indeehub_api_refresh')) {
|
||||
token = await nip98Service.refresh()
|
||||
}
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Auto-refresh on 401
|
||||
// Auto-refresh on 401 (fallback if the proactive refresh above
|
||||
// didn't happen or the token expired mid-flight)
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
|
||||
@@ -98,12 +98,22 @@ class LibraryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a rent exists for a given content ID (for polling after payment).
|
||||
* Check if an active (non-expired) rent exists for a given content ID.
|
||||
* Returns the rental expiry when one exists.
|
||||
*/
|
||||
async checkRentExists(contentId: string): Promise<{ exists: boolean }> {
|
||||
async checkRentExists(contentId: string): Promise<{ exists: boolean; expiresAt?: string }> {
|
||||
return apiService.get(`/rents/content/${contentId}/exists`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the quote endpoint for a specific BTCPay invoice.
|
||||
* Returns the quote which includes a `paid` flag indicating settlement.
|
||||
* This also triggers server-side payment detection for route-hint invoices.
|
||||
*/
|
||||
async pollQuoteStatus(invoiceId: string): Promise<{ paid?: boolean }> {
|
||||
return apiService.patch(`/rents/lightning/${invoiceId}/quote`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to content
|
||||
*/
|
||||
|
||||
@@ -15,7 +15,7 @@ const REFRESH_KEY = 'indeehub_api_refresh'
|
||||
const EXPIRES_KEY = 'indeehub_api_expires'
|
||||
|
||||
class Nip98Service {
|
||||
private refreshPromise: Promise<string> | null = null
|
||||
private refreshPromise: Promise<string | null> | null = null
|
||||
|
||||
/**
|
||||
* Check if we have a valid (non-expired) API token
|
||||
@@ -28,13 +28,34 @@ class Nip98Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current access token
|
||||
* Get the current access token.
|
||||
* Returns null if the token is missing or the client-side expiry
|
||||
* has passed. The 401 interceptor will then trigger a refresh.
|
||||
*/
|
||||
get accessToken(): string | null {
|
||||
if (!this.hasValidToken) return null
|
||||
return sessionStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `exp` claim from a JWT without verifying the signature.
|
||||
* Returns the expiry as a Unix **millisecond** timestamp, or null
|
||||
* if the token can't be decoded.
|
||||
*/
|
||||
private parseJwtExpiryMs(token: string): number | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
const payload = JSON.parse(atob(parts[1]))
|
||||
if (typeof payload.exp === 'number') {
|
||||
return payload.exp * 1000 // convert seconds → ms
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session with the backend using a NIP-98 auth event.
|
||||
*
|
||||
@@ -89,6 +110,7 @@ class Nip98Service {
|
||||
// Store tokens
|
||||
sessionStorage.setItem(TOKEN_KEY, accessToken)
|
||||
sessionStorage.setItem(REFRESH_KEY, refreshToken)
|
||||
sessionStorage.setItem('refresh_token', refreshToken)
|
||||
sessionStorage.setItem(
|
||||
EXPIRES_KEY,
|
||||
String(Date.now() + (expiresIn * 1000) - 30000) // 30s buffer
|
||||
@@ -124,7 +146,10 @@ class Nip98Service {
|
||||
const { accessToken, refreshToken: newRefresh, expiresIn } = response.data
|
||||
|
||||
sessionStorage.setItem(TOKEN_KEY, accessToken)
|
||||
if (newRefresh) sessionStorage.setItem(REFRESH_KEY, newRefresh)
|
||||
if (newRefresh) {
|
||||
sessionStorage.setItem(REFRESH_KEY, newRefresh)
|
||||
sessionStorage.setItem('refresh_token', newRefresh)
|
||||
}
|
||||
sessionStorage.setItem(
|
||||
EXPIRES_KEY,
|
||||
String(Date.now() + (expiresIn * 1000) - 30000)
|
||||
@@ -146,15 +171,38 @@ class Nip98Service {
|
||||
/**
|
||||
* Store tokens from an external auth flow (e.g. auth.service.ts).
|
||||
* Keeps nip98Service in sync so IndeehubApiService can read the token.
|
||||
*
|
||||
* When `expiresIn` is omitted the method parses the JWT's `exp`
|
||||
* claim so we know the **real** backend expiry rather than blindly
|
||||
* assuming 1 hour. If the token is already expired (e.g. restored
|
||||
* from sessionStorage after a long idle period), hasValidToken will
|
||||
* correctly return false and the 401 interceptor will trigger a
|
||||
* refresh via the refresh token.
|
||||
*/
|
||||
storeTokens(accessToken: string, refreshToken?: string, expiresIn?: number) {
|
||||
sessionStorage.setItem(TOKEN_KEY, accessToken)
|
||||
if (refreshToken) {
|
||||
sessionStorage.setItem(REFRESH_KEY, refreshToken)
|
||||
// Also store as 'refresh_token' so apiService can auto-refresh
|
||||
sessionStorage.setItem('refresh_token', refreshToken)
|
||||
}
|
||||
// Default to 1 hour if expiresIn is not provided
|
||||
const ttlMs = (expiresIn ?? 3600) * 1000
|
||||
sessionStorage.setItem(EXPIRES_KEY, String(Date.now() + ttlMs - 30000))
|
||||
|
||||
let expiresAtMs: number
|
||||
if (expiresIn !== undefined) {
|
||||
// Explicit TTL provided by the backend response
|
||||
expiresAtMs = Date.now() + expiresIn * 1000 - 30_000 // 30s safety buffer
|
||||
} else {
|
||||
// Try to extract the real expiry from the JWT
|
||||
const jwtExpiry = this.parseJwtExpiryMs(accessToken)
|
||||
if (jwtExpiry) {
|
||||
expiresAtMs = jwtExpiry - 30_000 // 30s safety buffer
|
||||
} else {
|
||||
// Absolute fallback — assume 1 hour (but this path should be rare)
|
||||
expiresAtMs = Date.now() + 3_600_000 - 30_000
|
||||
}
|
||||
}
|
||||
sessionStorage.setItem(EXPIRES_KEY, String(expiresAtMs))
|
||||
|
||||
// Backwards compatibility
|
||||
sessionStorage.setItem('nostr_token', accessToken)
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ class SubscriptionService {
|
||||
*/
|
||||
async createLightningSubscription(data: {
|
||||
type: 'enthusiast' | 'film-buff' | 'cinephile'
|
||||
period: 'monthly' | 'annual'
|
||||
period: 'monthly' | 'yearly'
|
||||
}): Promise<{
|
||||
lnInvoice: string
|
||||
expiration: string
|
||||
@@ -72,7 +72,7 @@ class SubscriptionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription tiers with pricing
|
||||
* Get subscription tiers with pricing (in sats)
|
||||
*/
|
||||
async getSubscriptionTiers(): Promise<Array<{
|
||||
tier: string
|
||||
@@ -81,26 +81,23 @@ class SubscriptionService {
|
||||
annualPrice: number
|
||||
features: string[]
|
||||
}>> {
|
||||
// This might be a static endpoint or hardcoded
|
||||
// Adjust based on actual API
|
||||
return [
|
||||
{
|
||||
tier: 'enthusiast',
|
||||
name: 'Enthusiast',
|
||||
monthlyPrice: 9.99,
|
||||
annualPrice: 99.99,
|
||||
monthlyPrice: 10000,
|
||||
annualPrice: 100000,
|
||||
features: [
|
||||
'Access to all films and series',
|
||||
'HD streaming',
|
||||
'Watch on 2 devices',
|
||||
'Cancel anytime',
|
||||
],
|
||||
},
|
||||
{
|
||||
tier: 'film-buff',
|
||||
name: 'Film Buff',
|
||||
monthlyPrice: 19.99,
|
||||
annualPrice: 199.99,
|
||||
monthlyPrice: 21000,
|
||||
annualPrice: 210000,
|
||||
features: [
|
||||
'Everything in Enthusiast',
|
||||
'4K streaming',
|
||||
@@ -112,14 +109,13 @@ class SubscriptionService {
|
||||
{
|
||||
tier: 'cinephile',
|
||||
name: 'Cinephile',
|
||||
monthlyPrice: 29.99,
|
||||
annualPrice: 299.99,
|
||||
monthlyPrice: 42000,
|
||||
annualPrice: 420000,
|
||||
features: [
|
||||
'Everything in Film Buff',
|
||||
'Watch on unlimited devices',
|
||||
'Offline downloads',
|
||||
'Director commentary tracks',
|
||||
'Virtual festival access',
|
||||
'Support independent filmmakers',
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user