managarten/packages/credits/src/createCreditService.ts
Till JS 11db6c60dc refactor(packages): consolidate 3 credit packages into @manacore/credits
Merged credit-operations + shared-credit-service + shared-credit-ui
into @manacore/credits with sub-path exports:
- @manacore/credits — operations, costs, service
- @manacore/credits/web — Svelte components
- @manacore/credits/mobile — React Native components

Package count: 47 → 44

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:46:26 +01:00

329 lines
8 KiB
TypeScript

/**
* Credit Service Factory
*
* Creates a credit service instance configured for a specific app.
* Handles credit balance fetching, pricing, and consumption notifications.
*
* @example
* ```ts
* import { createCreditService } from './createCreditService';
* import { auth } from '$lib/stores/auth';
*
* export const creditService = createCreditService({
* apiUrl: 'https://api.example.com',
* pricingEndpoint: '/credits/pricing',
* balanceEndpoint: '/auth/credits',
* getAuthToken: () => auth.getToken(),
* fallbackPricing: {
* STORY_CREATION: 10,
* CHARACTER_CREATION: 20
* }
* });
* ```
*/
import type {
CreditServiceConfig,
CreditBalance,
CreditCheckResponse,
PricingResponse,
CreditUpdateCallback,
StandardOperationType,
} from './service-types';
import { DEFAULT_OPERATION_PRICING } from './service-types';
/**
* Create a credit service instance
*/
export function createCreditService(config: CreditServiceConfig) {
const {
apiUrl,
balanceEndpoint = '/auth/credits',
pricingEndpoint = '/credits/pricing',
cacheDuration = 30 * 60 * 1000, // 30 minutes default
fallbackPricing = {},
getAuthToken,
} = config;
// Normalize API URL (remove trailing slash)
const baseUrl = apiUrl.replace(/\/$/, '');
// Internal state
let cachedPricing: PricingResponse | null = null;
let pricingLastFetched = 0;
const creditUpdateCallbacks: CreditUpdateCallback[] = [];
// Merge fallback pricing with defaults
const mergedFallbackPricing = {
...DEFAULT_OPERATION_PRICING,
...fallbackPricing,
};
/**
* Initialize the credit service by preloading pricing
*/
async function initialize(): Promise<void> {
try {
await getPricing();
console.log('[CreditService] Initialized with backend pricing');
} catch (error) {
console.warn('[CreditService] Initialization failed, using fallback pricing:', error);
}
}
/**
* Register a callback for credit consumption notifications
* @returns Unsubscribe function
*/
function onCreditUpdate(callback: CreditUpdateCallback): () => void {
creditUpdateCallbacks.push(callback);
return () => {
const index = creditUpdateCallbacks.indexOf(callback);
if (index > -1) {
creditUpdateCallbacks.splice(index, 1);
}
};
}
/**
* Notify all callbacks about credit consumption
*/
function notifyCreditUpdate(creditsConsumed: number, operation?: string): void {
creditUpdateCallbacks.forEach((callback) => {
try {
callback(creditsConsumed, operation);
} catch (error) {
console.error('[CreditService] Error in credit update callback:', error);
}
});
}
/**
* Manually trigger credit update notifications
*/
function triggerCreditUpdate(creditsConsumed: number, operation?: string): void {
notifyCreditUpdate(creditsConsumed, operation);
}
/**
* Fetch pricing information from backend with caching
*/
async function getPricing(): Promise<PricingResponse> {
const now = Date.now();
// Return cached pricing if still valid
if (cachedPricing && now - pricingLastFetched < cacheDuration) {
return cachedPricing;
}
try {
const response = await fetch(`${baseUrl}${pricingEndpoint}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const pricing = await response.json();
cachedPricing = pricing;
pricingLastFetched = now;
return pricing;
} catch (error) {
console.error('[CreditService] Error fetching pricing:', error);
// Return cached pricing if available
if (cachedPricing) {
return cachedPricing;
}
// Return fallback pricing
return {
operationCosts: mergedFallbackPricing,
lastUpdated: new Date().toISOString(),
};
}
}
/**
* Get user's credit balance
*/
async function getBalance(): Promise<CreditBalance | null> {
try {
const token = await getAuthToken();
if (!token) {
console.error('[CreditService] No authentication token available');
return null;
}
const response = await fetch(`${baseUrl}${balanceEndpoint}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP ${response.status}`);
}
const data = await response.json();
// Handle wrapped response structure
if (data.data && typeof data.data.credits === 'number') {
return {
credits: data.data.credits,
maxCreditLimit: data.data.maxCreditLimit ?? data.data.credits,
userId: data.data.userId ?? '',
lastUpdated: new Date().toISOString(),
};
}
// Handle direct structure
if (typeof data.credits === 'number') {
return {
credits: data.credits,
maxCreditLimit: data.maxCreditLimit ?? data.credits,
userId: data.userId ?? '',
lastUpdated: new Date().toISOString(),
};
}
return data;
} catch (error) {
console.error('[CreditService] Error fetching balance:', error);
return null;
}
}
/**
* Get cost for a specific operation (async with backend fetch)
*/
async function getOperationCost(operation: StandardOperationType): Promise<number> {
try {
const pricing = await getPricing();
return pricing.operationCosts[operation] ?? mergedFallbackPricing[operation] ?? 0;
} catch (error) {
console.error('[CreditService] Error getting operation cost:', error);
return mergedFallbackPricing[operation] ?? 0;
}
}
/**
* Get cost for a specific operation (sync, uses cached values)
*/
function getOperationCostSync(operation: StandardOperationType): number {
if (cachedPricing?.operationCosts[operation] !== undefined) {
return cachedPricing.operationCosts[operation];
}
return mergedFallbackPricing[operation] ?? 0;
}
/**
* Calculate cost for multiple units of an operation
*/
async function calculateCost(operation: StandardOperationType, quantity = 1): Promise<number> {
const unitCost = await getOperationCost(operation);
return unitCost * quantity;
}
/**
* Calculate cost synchronously (uses cached values)
*/
function calculateCostSync(operation: StandardOperationType, quantity = 1): number {
const unitCost = getOperationCostSync(operation);
return unitCost * quantity;
}
/**
* Check if user has enough credits for an operation
*/
async function checkBalance(
requiredCredits: number,
operation?: string
): Promise<CreditCheckResponse> {
const balance = await getBalance();
if (!balance) {
return {
hasEnoughCredits: false,
currentCredits: 0,
requiredCredits,
deficit: requiredCredits,
};
}
const hasEnough = balance.credits >= requiredCredits;
return {
hasEnoughCredits: hasEnough,
currentCredits: balance.credits,
requiredCredits,
deficit: hasEnough ? undefined : requiredCredits - balance.credits,
context: operation ? { operation } : undefined,
};
}
/**
* Check if user has enough credits for a specific operation
*/
async function checkOperationBalance(
operation: StandardOperationType
): Promise<CreditCheckResponse> {
const cost = await getOperationCost(operation);
return checkBalance(cost, operation);
}
/**
* Format credit amount for display
*/
function formatCredits(amount: number, locale = 'en-US'): string {
return new Intl.NumberFormat(locale).format(amount);
}
/**
* Clear pricing cache (useful for testing or forced refresh)
*/
function clearCache(): void {
cachedPricing = null;
pricingLastFetched = 0;
}
return {
// Initialization
initialize,
// Balance operations
getBalance,
checkBalance,
checkOperationBalance,
// Pricing operations
getPricing,
getOperationCost,
getOperationCostSync,
calculateCost,
calculateCostSync,
// Notifications
onCreditUpdate,
triggerCreditUpdate,
// Utilities
formatCredits,
clearCache,
};
}
/**
* Type for the credit service instance
*/
export type CreditService = ReturnType<typeof createCreditService>;