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:
Dorian
2026-02-12 23:24:25 +00:00
parent cdd24a5def
commit 0da83f461c
39 changed files with 1182 additions and 270 deletions

View File

@@ -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

View File

@@ -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) => {

View File

@@ -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
*/

View File

@@ -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)
}

View File

@@ -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',
],
},