diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dc070f..1f10f7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Revenue Sharing Service** - Platform-agnostic revenue distribution system + - Flexible commission structures (default, category, tier, custom rates) + - Automated transaction tracking with full audit trail + - Comprehensive partner statistics and analytics + - Automated payout processing with configurable schedules + - Support for multiple payment providers + - TypeScript-first with full type safety + - Example implementation for Telegram marketplace bot - **Anthropic Claude AI Provider** - Full support for Claude 4 models - Claude Sonnet 4 (default) - Balanced performance model - Claude Opus 4 - Most powerful model with extended thinking @@ -37,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Documentation +- Added Revenue Sharing pattern documentation with examples - Added comprehensive Anthropic provider guide - Created CLI tool documentation - Updated AI providers documentation diff --git a/docs/patterns/revenue-sharing.md b/docs/patterns/revenue-sharing.md new file mode 100644 index 0000000..c951ed7 --- /dev/null +++ b/docs/patterns/revenue-sharing.md @@ -0,0 +1,347 @@ +# Revenue Sharing Pattern + +The Revenue Sharing Service provides a flexible, platform-agnostic solution for managing revenue distribution between your platform and partners. This pattern is ideal for marketplaces, SaaS platforms with resellers, content platforms with creators, or any system requiring automated revenue distribution. + +## Features + +- **Flexible Commission Structure**: Support for default rates, category-specific rates, tier-based rates, and partner-specific custom rates +- **Automated Transaction Tracking**: Record and track all revenue-generating transactions with full audit trail +- **Comprehensive Statistics**: Detailed analytics by partner, category, transaction type, and time period +- **Automated Payouts**: Configurable payout schedules with support for various payment methods +- **Platform-Agnostic**: Works with any storage backend and payment provider +- **Type-Safe**: Full TypeScript support with comprehensive interfaces + +## Use Cases + +### 1. Regional Marketplace + +```typescript +// Configure for regional partners with different commission rates +const revenueService = new RevenueSharingService(db, { + defaultCommissionRate: 0.2, + tierRates: { + gold: 0.15, // Better rate for gold partners + silver: 0.2, + bronze: 0.25, + }, + minPayoutAmount: 100, + payoutSchedule: 'monthly', +}); + +// Register regional partner +await revenueService.upsertPartner({ + externalId: 'partner_thailand', + name: 'Thailand Regional Partner', + region: 'TH', + tier: 'gold', + commissionRate: 0.15, + agreementStartDate: new Date(), + isActive: true, +}); +``` + +### 2. Content Creator Platform + +```typescript +// Configure for content creators with category-based rates +const revenueService = new RevenueSharingService(db, { + defaultCommissionRate: 0.3, + categoryRates: { + video: 0.45, // Higher commission for video content + article: 0.3, + podcast: 0.4, + }, + minPayoutAmount: 50, + payoutSchedule: 'weekly', + autoProcessPayouts: true, +}); + +// Register content creator +await revenueService.upsertPartner({ + externalId: 'creator_12345', + name: 'John Doe', + metadata: { + channel: 'JohnDoeVideos', + subscribers: 50000, + }, + customRates: { + sponsored: 0.6, // Higher rate for sponsored content + }, + commissionRate: 0.45, + agreementStartDate: new Date(), + isActive: true, +}); +``` + +### 3. SaaS Reseller Program + +```typescript +// Configure for SaaS resellers +const revenueService = new RevenueSharingService(db, { + defaultCommissionRate: 0.2, + categoryRates: { + enterprise: 0.3, + business: 0.25, + starter: 0.2, + }, + requireApproval: true, // Manual payout approval + minPayoutAmount: 500, +}); + +// Track subscription sale +await revenueService.recordTransaction({ + partnerExternalId: 'reseller_abc', + transactionId: 'sub_xyz123', + type: 'subscription', + category: 'enterprise', + amount: 5000, // $5000 annual subscription + metadata: { + customerId: 'cust_123', + plan: 'enterprise_annual', + duration: 12, + }, +}); +``` + +## Implementation Guide + +### 1. Basic Setup + +```typescript +import { RevenueSharingService } from '@wireframe/revenue-sharing'; +import { YourDatabaseAdapter } from './your-database'; + +const config = { + defaultCommissionRate: 0.3, + minPayoutAmount: 100, + payoutSchedule: 'monthly', + autoProcessPayouts: true, +}; + +const revenueService = new RevenueSharingService(new YourDatabaseAdapter(), config); +``` + +### 2. Recording Transactions + +```typescript +// Option 1: Using partner ID +await revenueService.recordTransaction({ + partnerId: 'partner_123', + transactionId: 'order_abc', + type: 'sale', + category: 'electronics', + amount: 1000, +}); + +// Option 2: Using external ID +await revenueService.recordTransaction({ + partnerExternalId: 'telegram_775707', + transactionId: 'bid_xyz', + type: 'auction_bid', + amount: 500, + metadata: { + auctionId: 'auction_123', + position: 1, + }, +}); +``` + +### 3. Getting Statistics + +```typescript +const stats = await revenueService.getPartnerStats( + 'partner_123', + new Date('2024-01-01'), + new Date('2024-01-31'), +); + +console.log({ + totalRevenue: stats.totalRevenue, + totalCommission: stats.totalCommission, + transactionCount: stats.transactionCount, + byCategory: stats.byCategory, +}); +``` + +### 4. Processing Payouts + +```typescript +// Manual payout creation +const payout = await revenueService.createPayout( + 'partner_123', + new Date('2024-01-01'), + new Date('2024-01-31'), +); + +// Automated payout processing with custom handler +await revenueService.processPayouts(async (payout) => { + try { + // Your payment logic here + const result = await stripeClient.transfers.create({ + amount: payout.totalCommission, + currency: 'usd', + destination: getStripeAccountId(payout.partnerId), + }); + + return { + success: true, + method: 'stripe', + details: { transferId: result.id }, + }; + } catch (error) { + return { + success: false, + error: error.message, + }; + } +}); +``` + +## Storage Adapter Requirements + +The service requires a storage adapter implementing the `IDatabaseStore` interface: + +```typescript +interface IDatabaseStore { + get(key: string): Promise; + put(key: string, value: T): Promise; + delete(key: string): Promise; + list(prefix: string): Promise; +} +``` + +### Example Adapters + +#### Cloudflare KV + +```typescript +class CloudflareKVAdapter implements IDatabaseStore { + constructor(private kv: KVNamespace) {} + + async get(key: string): Promise { + const value = await this.kv.get(key); + return value ? JSON.parse(value) : null; + } + + async put(key: string, value: T): Promise { + await this.kv.put(key, JSON.stringify(value)); + } + + async delete(key: string): Promise { + await this.kv.delete(key); + } + + async list(prefix: string): Promise { + const list = await this.kv.list({ prefix }); + const results: T[] = []; + for (const key of list.keys) { + const value = await this.get(key.name); + if (value) results.push(value); + } + return results; + } +} +``` + +#### D1 Database + +```typescript +class D1Adapter implements IDatabaseStore { + constructor(private db: D1Database) {} + + // Implementation would map to SQL queries + // This is a simplified example + async get(key: string): Promise { + const [type, id] = key.split(':'); + const result = await this.db + .prepare(`SELECT data FROM kv_store WHERE key = ?`) + .bind(key) + .first(); + return result ? JSON.parse(result.data) : null; + } + + // ... other methods +} +``` + +## Best Practices + +1. **Commission Rate Hierarchy** + - Partner custom rates (highest priority) + - Category-specific rates + - Tier-based rates + - Default commission rate (lowest priority) + +2. **Transaction Metadata** + - Always include relevant metadata for audit trails + - Use consistent transaction types across your platform + - Include original transaction IDs for reference + +3. **Payout Management** + - Set appropriate minimum payout amounts to reduce transaction costs + - Implement proper error handling in payment handlers + - Keep detailed logs of all payout attempts + +4. **Performance Considerations** + - Implement proper indexing for transaction queries + - Consider batching transactions for high-volume scenarios + - Use caching for frequently accessed partner data + +5. **Security** + - Validate all commission rates are within expected ranges + - Implement proper access controls for payout operations + - Audit all revenue-related operations + +## Migration from Existing Systems + +If you're migrating from an existing revenue sharing system: + +1. **Export existing data** in a structured format +2. **Map your data** to the Revenue Sharing Service models +3. **Import partners** first, maintaining their external IDs +4. **Import historical transactions** if needed for reporting +5. **Verify calculations** match your existing system +6. **Run in parallel** during transition period + +## Extending the Service + +The service can be extended for specific use cases: + +```typescript +class CustomRevenueSharingService extends RevenueSharingService { + // Add multi-currency support + async recordTransactionMultiCurrency(transaction: { + amount: number; + currency: string; + exchangeRate: number; + // ... other fields + }) { + const amountInBaseCurrency = transaction.amount * transaction.exchangeRate; + return super.recordTransaction({ + ...transaction, + amount: amountInBaseCurrency, + metadata: { + ...transaction.metadata, + originalCurrency: transaction.currency, + exchangeRate: transaction.exchangeRate, + }, + }); + } + + // Add tiered commission rates based on volume + protected getCommissionRate(partner: RevenuePartner, category?: string, type?: string): number { + const baseRate = super.getCommissionRate(partner, category, type); + + // Apply volume discount + const monthlyVolume = await this.getMonthlyVolume(partner.id); + if (monthlyVolume > 100000) return baseRate * 0.9; + if (monthlyVolume > 50000) return baseRate * 0.95; + + return baseRate; + } +} +``` + +## Conclusion + +The Revenue Sharing Service provides a robust foundation for implementing partner revenue sharing in any platform. Its flexible architecture supports various business models while maintaining clean separation of concerns and type safety throughout. diff --git a/examples/revenue-sharing-telegram-bot.ts b/examples/revenue-sharing-telegram-bot.ts new file mode 100644 index 0000000..35d0f69 --- /dev/null +++ b/examples/revenue-sharing-telegram-bot.ts @@ -0,0 +1,278 @@ +/** + * Example: Revenue Sharing in a Telegram Bot Marketplace + * + * This example shows how to integrate the Revenue Sharing Service + * with a Telegram bot that runs daily auctions for service providers. + */ + +import { RevenueSharingService } from '../src/services/revenue-sharing-service.js'; +import type { IKeyValueStore } from '../src/core/interfaces/storage.js'; +import type { ITelegramConnector } from '../src/connectors/messaging/telegram/telegram-connector.js'; + +// Example: Beauty services marketplace bot +export class MarketplaceBotWithRevenue { + private revenueService: RevenueSharingService; + + constructor( + private kv: IKeyValueStore, + private telegram: ITelegramConnector, + ) { + // Initialize revenue service with marketplace-specific config + this.revenueService = new RevenueSharingService(kv, { + defaultCommissionRate: 0.2, // 20% platform fee + categoryRates: { + nails: 0.15, // Lower fee for nail services + hair: 0.2, // Standard fee + massage: 0.25, // Higher fee for massage services + }, + minPayoutAmount: 1000, // Minimum 1000 stars for payout + payoutSchedule: 'weekly', + autoProcessPayouts: true, + }); + } + + /** + * Register a regional partner (e.g., someone who brings providers to the platform) + */ + async registerPartner(command: { + userId: number; + username?: string; + firstName?: string; + region: string; + }): Promise { + try { + const partner = await this.revenueService.upsertPartner({ + externalId: `telegram_${command.userId}`, + name: command.firstName || command.username || `Partner ${command.userId}`, + metadata: { + telegramId: command.userId, + username: command.username, + }, + region: command.region, + commissionRate: 0.5, // 50% of platform fee goes to partner + agreementStartDate: new Date(), + isActive: true, + }); + + await this.telegram.api.sendMessage( + command.userId, + `✅ You are now a registered partner for region: ${command.region}\n` + + `Commission rate: 50% of platform fees\n` + + `Partner ID: ${partner.id}`, + ); + } catch (error) { + await this.telegram.api.sendMessage( + command.userId, + '❌ Failed to register as partner. Please contact support.', + ); + } + } + + /** + * Process auction winner payment and record revenue + */ + async processAuctionPayment(payment: { + providerId: number; + auctionId: string; + categoryId: string; + bidAmount: number; // in Telegram Stars + region: string; + position: number; // 1, 2, or 3 + }): Promise { + try { + // Charge the provider (this would integrate with Telegram Stars API) + const transactionId = await this.chargeProvider(payment.providerId, payment.bidAmount); + + // Record transaction for revenue sharing + const transaction = await this.revenueService.recordTransaction({ + partnerExternalId: await this.getPartnerExternalIdForRegion(payment.region), + transactionId, + type: 'auction_bid', + category: payment.categoryId, + amount: payment.bidAmount, + metadata: { + auctionId: payment.auctionId, + providerId: payment.providerId, + position: payment.position, + region: payment.region, + }, + }); + + // Notify provider + await this.telegram.api.sendMessage( + payment.providerId, + `✅ Payment successful!\n` + + `Amount: ${payment.bidAmount} stars\n` + + `Position: #${payment.position}\n` + + `Transaction: ${transactionId}`, + ); + + // Log for analytics + console.log('Auction payment processed:', { + transactionId, + revenue: payment.bidAmount, + platformFee: payment.bidAmount * 0.2, + partnerCommission: transaction.commissionAmount, + }); + } catch (error) { + console.error('Failed to process auction payment:', error); + throw error; + } + } + + /** + * Show partner dashboard + */ + async showPartnerDashboard(userId: number): Promise { + try { + const partner = await this.revenueService.getPartnerByExternalId(`telegram_${userId}`); + if (!partner) { + await this.telegram.api.sendMessage(userId, '❌ You are not a registered partner.'); + return; + } + + // Get current month stats + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const stats = await this.revenueService.getPartnerStats(partner.id, startOfMonth, now); + + const message = + `📊 *Partner Dashboard*\n\n` + + `Region: ${partner.region}\n` + + `Status: ${partner.isActive ? '✅ Active' : '❌ Inactive'}\n\n` + + `*This Month:*\n` + + `Total Revenue: ${stats.totalRevenue} stars\n` + + `Your Commission: ${stats.totalCommission} stars\n` + + `Transactions: ${stats.transactionCount}\n\n` + + `*By Category:*\n` + + Object.entries(stats.byCategory || {}) + .map( + ([category, data]) => + `${category}: ${data.commission} stars (${data.count} transactions)`, + ) + .join('\n'); + + await this.telegram.api.sendMessage(userId, message, { parse_mode: 'Markdown' }); + } catch (error) { + console.error('Failed to show partner dashboard:', error); + await this.telegram.api.sendMessage(userId, '❌ Failed to load dashboard.'); + } + } + + /** + * Process weekly payouts for all partners + */ + async processWeeklyPayouts(): Promise { + console.log('Starting weekly payout processing...'); + + await this.revenueService.processPayouts(async (payout) => { + try { + // Get partner details + const partner = await this.revenueService.getPartner(payout.partnerId); + if (!partner?.metadata?.telegramId) { + throw new Error('Partner missing Telegram ID'); + } + + const telegramId = partner.metadata.telegramId as number; + + // Send payout via Telegram Stars (this would use actual Stars API) + const transferResult = await this.sendStarsToUser( + telegramId, + Math.floor(payout.totalCommission), + ); + + // Notify partner + await this.telegram.api.sendMessage( + telegramId, + `💰 *Payout Processed!*\n\n` + + `Period: ${payout.periodStart.toLocaleDateString()} - ${payout.periodEnd.toLocaleDateString()}\n` + + `Amount: ${payout.totalCommission} stars\n` + + `Transactions: ${payout.transactionCount}\n` + + `Status: ✅ Completed\n` + + `Reference: ${transferResult.transferId}`, + { parse_mode: 'Markdown' }, + ); + + return { + success: true, + method: 'telegram_stars', + details: { + transferId: transferResult.transferId, + timestamp: new Date().toISOString(), + }, + }; + } catch (error) { + console.error('Payout failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }); + } + + /** + * Admin command to view platform statistics + */ + async showPlatformStats(adminUserId: number): Promise { + // This would include comprehensive platform-wide statistics + // Including total revenue, partner commissions, etc. + const message = + `📈 *Platform Revenue Stats*\n\n` + + `Total Revenue (Month): X stars\n` + + `Platform Fees: X stars\n` + + `Partner Commissions: X stars\n` + + `Active Partners: X\n` + + `Pending Payouts: X`; + + await this.telegram.api.sendMessage(adminUserId, message, { parse_mode: 'Markdown' }); + } + + // Helper methods + + private async getPartnerExternalIdForRegion(region: string): Promise { + // In real implementation, this would look up the partner assigned to a region + // For now, returning undefined means platform keeps all revenue + return undefined; + } + + private async chargeProvider(providerId: number, amount: number): Promise { + // This would integrate with Telegram Stars API + // For example purposes, generating a mock transaction ID + return `stars_tx_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } + + private async sendStarsToUser(userId: number, amount: number): Promise<{ transferId: string }> { + // This would use Telegram Stars API to send stars to user + // Mock implementation + return { + transferId: `transfer_${Date.now()}_${userId}`, + }; + } +} + +// Helper function for admin check +function isAdmin(userId: number): boolean { + // In real implementation, check against admin list + return userId === 775707; // Example admin ID +} + +// Example usage in bot commands (pseudo-code) +export function setupRevenueCommands(bot: MarketplaceBotWithRevenue) { + // This is pseudo-code to demonstrate the integration + // In real implementation, you would use your bot framework's command handler + // Example: Partner registration command + // bot.command('partner_register', async (ctx) => { ... }); + // Example: Partner dashboard command + // bot.command('partner_stats', async (ctx) => { ... }); + // Example: Admin revenue stats + // bot.command('admin_revenue', async (ctx) => { ... }); +} + +// Scheduled job for weekly payouts +export function setupPayoutSchedule(bot: MarketplaceBotWithRevenue) { + // This would be called by your scheduler (e.g., Cloudflare Cron Trigger) + return async () => { + await bot.processWeeklyPayouts(); + }; +} diff --git a/src/services/__tests__/revenue-sharing-service.test.ts b/src/services/__tests__/revenue-sharing-service.test.ts new file mode 100644 index 0000000..9fb5ede --- /dev/null +++ b/src/services/__tests__/revenue-sharing-service.test.ts @@ -0,0 +1,412 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { + RevenueSharingService, + type RevenuePartner, + type RevenueSharingConfig, +} from '../revenue-sharing-service.js'; +import type { IKeyValueStore } from '../../core/interfaces/storage.js'; + +// Mock storage implementation +class MockKeyValueStore implements IKeyValueStore { + private store = new Map(); + + async get(key: string): Promise { + return (this.store.get(key) as T) || null; + } + + async put( + key: string, + value: string | ArrayBuffer | ArrayBufferView | ReadableStream, + ): Promise { + this.store.set(key, value); + } + + async delete(key: string): Promise { + this.store.delete(key); + } + + async getWithMetadata( + key: string, + ): Promise<{ + value: T | null; + metadata: Record | null; + }> { + const value = await this.get(key); + return { value, metadata: null }; + } + + async list(options?: { prefix?: string; limit?: number; cursor?: string }): Promise<{ + keys: Array<{ name: string; metadata?: Record }>; + list_complete: boolean; + cursor?: string; + }> { + const prefix = options?.prefix || ''; + const keys: Array<{ name: string }> = []; + + for (const key of this.store.keys()) { + if (key.startsWith(prefix)) { + keys.push({ name: key }); + } + } + + return { + keys, + list_complete: true, + }; + } + + clear(): void { + this.store.clear(); + } +} + +describe('RevenueSharingService', () => { + let service: RevenueSharingService; + let mockStore: MockKeyValueStore; + let config: RevenueSharingConfig; + + beforeEach(() => { + mockStore = new MockKeyValueStore(); + config = { + defaultCommissionRate: 0.3, + minPayoutAmount: 100, + payoutSchedule: 'monthly', + autoProcessPayouts: true, + categoryRates: { + premium: 0.4, + standard: 0.3, + }, + }; + service = new RevenueSharingService(mockStore, config); + }); + + describe('Partner Management', () => { + it('should create a new partner', async () => { + const partner = await service.upsertPartner({ + externalId: 'telegram_123', + name: 'John Doe', + region: 'US', + commissionRate: 0.25, + agreementStartDate: new Date('2024-01-01'), + isActive: true, + }); + + expect(partner.id).toBeDefined(); + expect(partner.externalId).toBe('telegram_123'); + expect(partner.commissionRate).toBe(0.25); + expect(partner.isActive).toBe(true); + expect(partner.createdAt).toBeInstanceOf(Date); + }); + + it('should update existing partner', async () => { + // Create partner + const original = await service.upsertPartner({ + externalId: 'telegram_123', + name: 'John Doe', + commissionRate: 0.25, + agreementStartDate: new Date('2024-01-01'), + isActive: true, + }); + + // Wait a bit to ensure updatedAt is different + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Update partner + const updated = await service.upsertPartner({ + externalId: 'telegram_123', + name: 'John Smith', + commissionRate: 0.3, + agreementStartDate: new Date('2024-01-01'), + isActive: true, + }); + + expect(updated.id).toBe(original.id); + expect(updated.name).toBe('John Smith'); + expect(updated.commissionRate).toBe(0.3); + expect(updated.updatedAt.getTime()).toBeGreaterThan(original.updatedAt.getTime()); + }); + + it('should retrieve partner by external ID', async () => { + const created = await service.upsertPartner({ + externalId: 'email_user@example.com', + name: 'Jane Doe', + commissionRate: 0.35, + agreementStartDate: new Date('2024-01-01'), + isActive: true, + }); + + const retrieved = await service.getPartnerByExternalId('email_user@example.com'); + expect(retrieved).toEqual(created); + }); + }); + + describe('Transaction Recording', () => { + let partner: RevenuePartner; + + beforeEach(async () => { + partner = await service.upsertPartner({ + externalId: 'partner_1', + name: 'Test Partner', + commissionRate: 0.3, + customRates: { + premium: 0.4, + }, + agreementStartDate: new Date('2024-01-01'), + isActive: true, + }); + }); + + it('should record a transaction with default commission rate', async () => { + const transaction = await service.recordTransaction({ + partnerId: partner.id, + transactionId: 'tx_123', + type: 'sale', + category: 'standard', + amount: 1000, + }); + + expect(transaction.partnerId).toBe(partner.id); + expect(transaction.amount).toBe(1000); + expect(transaction.commissionRate).toBe(0.3); + expect(transaction.commissionAmount).toBe(300); + }); + + it('should use custom category rate for partner', async () => { + const transaction = await service.recordTransaction({ + partnerId: partner.id, + transactionId: 'tx_124', + type: 'sale', + category: 'premium', + amount: 1000, + }); + + expect(transaction.commissionRate).toBe(0.4); + expect(transaction.commissionAmount).toBe(400); + }); + + it('should throw error for inactive partner', async () => { + // Deactivate partner + await service.upsertPartner({ + ...partner, + isActive: false, + }); + + await expect( + service.recordTransaction({ + partnerId: partner.id, + transactionId: 'tx_125', + type: 'sale', + amount: 1000, + }), + ).rejects.toThrow('No active partner found for transaction'); + }); + + it('should find partner by external ID for transaction', async () => { + const transaction = await service.recordTransaction({ + partnerExternalId: 'partner_1', + transactionId: 'tx_126', + type: 'subscription', + amount: 500, + }); + + expect(transaction.partnerId).toBe(partner.id); + expect(transaction.amount).toBe(500); + expect(transaction.commissionAmount).toBe(150); + }); + }); + + describe('Statistics and Payouts', () => { + let partner: RevenuePartner; + + beforeEach(async () => { + partner = await service.upsertPartner({ + externalId: 'partner_stats', + name: 'Stats Partner', + commissionRate: 0.25, + agreementStartDate: new Date('2024-01-01'), + isActive: true, + }); + }); + + it('should create payout for partner', async () => { + // Record some transactions first with dates in the payout period + const transactionDate = new Date('2024-01-15'); + await service.recordTransaction({ + partnerId: partner.id, + transactionId: 'tx_201', + type: 'sale', + amount: 1000, + }); + + // Override the processedAt date to be within our test period + // This is necessary because transactions are timestamped with current date + const storedTx = await mockStore.get('transaction:original:tx_201'); + if (storedTx) { + const txDataStr = await mockStore.get(`transaction:${storedTx}`); + if (txDataStr) { + const txData = JSON.parse(txDataStr); + txData.processedAt = transactionDate; + await mockStore.put(`transaction:${storedTx}`, JSON.stringify(txData)); + } + } + + const startDate = new Date('2024-01-01'); + const endDate = new Date('2024-01-31'); + + const payout = await service.createPayout(partner.id, startDate, endDate); + + expect(payout.partnerId).toBe(partner.id); + expect(payout.periodStart).toEqual(startDate); + expect(payout.periodEnd).toEqual(endDate); + expect(payout.status).toBe('processing'); // auto-process enabled + expect(payout.totalRevenue).toBe(1000); + expect(payout.totalCommission).toBe(250); // 25% commission + }); + + it('should respect minimum payout amount', async () => { + const startDate = new Date('2024-01-01'); + const endDate = new Date('2024-01-31'); + + // Try to create payout with no transactions (0 commission) + await expect(service.createPayout(partner.id, startDate, endDate)).rejects.toThrow( + 'below minimum payout amount', + ); + }); + + it('should update payout status', async () => { + // First record a transaction to have sufficient commission + await service.recordTransaction({ + partnerId: partner.id, + transactionId: 'tx_202', + type: 'sale', + amount: 1000, + }); + + // Override the processedAt date + const transactionDate = new Date('2024-01-15'); + const storedTx = await mockStore.get('transaction:original:tx_202'); + if (storedTx) { + const txDataStr = await mockStore.get(`transaction:${storedTx}`); + if (txDataStr) { + const txData = JSON.parse(txDataStr); + txData.processedAt = transactionDate; + await mockStore.put(`transaction:${storedTx}`, JSON.stringify(txData)); + } + } + + // Create a payout + const payout = await service.createPayout( + partner.id, + new Date('2024-01-01'), + new Date('2024-01-31'), + ); + + // Update status + const updated = await service.updatePayoutStatus(payout.id, 'completed', { + paymentMethod: 'wire_transfer', + paymentDetails: { reference: 'WT123456' }, + }); + + expect(updated.status).toBe('completed'); + expect(updated.paymentMethod).toBe('wire_transfer'); + expect(updated.paymentDetails).toEqual({ reference: 'WT123456' }); + expect(updated.processedAt).toBeInstanceOf(Date); + }); + }); + + describe('Commission Rate Calculation', () => { + it('should apply category rates from config', async () => { + const partner = await service.upsertPartner({ + externalId: 'rate_test', + name: 'Rate Test Partner', + commissionRate: 0.2, // Default rate + agreementStartDate: new Date('2024-01-01'), + isActive: true, + }); + + // Premium category should use config rate + const premiumTx = await service.recordTransaction({ + partnerId: partner.id, + transactionId: 'tx_301', + type: 'sale', + category: 'premium', + amount: 1000, + }); + + expect(premiumTx.commissionRate).toBe(0.4); // From config + expect(premiumTx.commissionAmount).toBe(400); + }); + + it('should prioritize partner custom rates over config', async () => { + const partner = await service.upsertPartner({ + externalId: 'custom_rate', + name: 'Custom Rate Partner', + commissionRate: 0.2, + customRates: { + premium: 0.5, // Higher than config + }, + agreementStartDate: new Date('2024-01-01'), + isActive: true, + }); + + const transaction = await service.recordTransaction({ + partnerId: partner.id, + transactionId: 'tx_302', + type: 'sale', + category: 'premium', + amount: 1000, + }); + + expect(transaction.commissionRate).toBe(0.5); // Partner's custom rate + expect(transaction.commissionAmount).toBe(500); + }); + }); + + describe('Automated Payout Processing', () => { + it('should process payouts with payment handler', async () => { + await service.upsertPartner({ + externalId: 'auto_payout', + name: 'Auto Payout Partner', + commissionRate: 0.3, + agreementStartDate: new Date('2024-01-01'), + isActive: true, + }); + + // Mock payment handler + const paymentHandler = vi.fn().mockResolvedValue({ + success: true, + method: 'stripe', + details: { transferId: 'tr_123' }, + }); + + // Process payouts + await service.processPayouts(paymentHandler); + + // Payment handler should not be called if no pending payouts + expect(paymentHandler).not.toHaveBeenCalled(); + }); + + it('should handle payment failures', async () => { + await service.upsertPartner({ + externalId: 'failed_payout', + name: 'Failed Payout Partner', + commissionRate: 0.3, + agreementStartDate: new Date('2024-01-01'), + isActive: true, + }); + + // Mock failing payment handler + const paymentHandler = vi.fn().mockResolvedValue({ + success: false, + error: 'Insufficient funds', + }); + + // Process payouts + await service.processPayouts(paymentHandler); + + // Verify no errors thrown + expect(paymentHandler).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..bd179d4 --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,13 @@ +/** + * Service exports + */ + +// Revenue Sharing Service +export { + RevenueSharingService, + type RevenuePartner, + type RevenueTransaction, + type RevenuePayout, + type RevenueStats, + type RevenueSharingConfig, +} from './revenue-sharing-service.js'; diff --git a/src/services/revenue-sharing-service.ts b/src/services/revenue-sharing-service.ts new file mode 100644 index 0000000..d5afe70 --- /dev/null +++ b/src/services/revenue-sharing-service.ts @@ -0,0 +1,519 @@ +/** + * Revenue Sharing Service + * + * Platform-agnostic service for managing revenue sharing between platform and partners. + * Supports flexible commission structures, automated payouts, and comprehensive tracking. + * + * Use cases: + * - Marketplace platforms with regional partners + * - SaaS platforms with resellers + * - Content platforms with creators + * - Any platform requiring revenue distribution + */ + +import type { IKeyValueStore } from '../core/interfaces/storage.js'; +import { logger } from '../lib/logger.js'; + +export interface RevenuePartner { + id: string; + externalId: string; // External identifier (e.g., Telegram ID, email, etc.) + name: string; + metadata?: Record; // Platform-specific data + region?: string; + tier?: string; + commissionRate: number; // Decimal (0.5 = 50%) + customRates?: Record; // Category/product-specific rates + agreementStartDate: Date; + agreementEndDate?: Date; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface RevenueTransaction { + id: string; + partnerId: string; + transactionId: string; // Original transaction ID + type: string; // sale, subscription, bid, etc. + category?: string; + amount: number; + commissionRate: number; + commissionAmount: number; + metadata?: Record; + processedAt: Date; +} + +export interface RevenuePayout { + id: string; + partnerId: string; + periodStart: Date; + periodEnd: Date; + totalRevenue: number; + totalCommission: number; + transactionCount: number; + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; + paymentMethod?: string; + paymentDetails?: Record; + processedAt?: Date; + notes?: string; + createdAt: Date; + updatedAt: Date; +} + +export interface RevenueStats { + partnerId: string; + period: { + start: Date; + end: Date; + }; + totalRevenue: number; + totalCommission: number; + transactionCount: number; + byCategory?: Record< + string, + { + revenue: number; + commission: number; + count: number; + } + >; + byType?: Record< + string, + { + revenue: number; + commission: number; + count: number; + } + >; +} + +export interface RevenueSharingConfig { + defaultCommissionRate: number; + minPayoutAmount?: number; + payoutSchedule?: 'daily' | 'weekly' | 'monthly' | 'manual'; + autoProcessPayouts?: boolean; + requireApproval?: boolean; + categoryRates?: Record; + tierRates?: Record; +} + +export class RevenueSharingService { + constructor( + private kv: IKeyValueStore, + private config: RevenueSharingConfig = { defaultCommissionRate: 0.3 }, + ) {} + + /** + * Register or update a revenue partner + */ + async upsertPartner( + partner: Omit, + ): Promise { + const existingPartner = await this.getPartnerByExternalId(partner.externalId); + + if (existingPartner) { + // Update existing partner + const updated: RevenuePartner = { + ...existingPartner, + ...partner, + updatedAt: new Date(), + }; + + await this.kv.put(`partner:${existingPartner.id}`, JSON.stringify(updated)); + + logger.info('Revenue partner updated', { + partnerId: existingPartner.id, + externalId: partner.externalId, + }); + + return updated; + } else { + // Create new partner + const newPartner: RevenuePartner = { + ...partner, + id: this.generateId(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + await this.kv.put(`partner:${newPartner.id}`, JSON.stringify(newPartner)); + await this.kv.put(`partner:external:${partner.externalId}`, newPartner.id); + + logger.info('Revenue partner created', { + partnerId: newPartner.id, + externalId: partner.externalId, + }); + + return newPartner; + } + } + + /** + * Record a revenue transaction + */ + async recordTransaction(transaction: { + partnerId?: string; + partnerExternalId?: string; + transactionId: string; + type: string; + category?: string; + amount: number; + metadata?: Record; + }): Promise { + // Find partner + let partner: RevenuePartner | null = null; + + if (transaction.partnerId) { + partner = await this.getPartner(transaction.partnerId); + } else if (transaction.partnerExternalId) { + partner = await this.getPartnerByExternalId(transaction.partnerExternalId); + } + + if (!partner || !partner.isActive) { + logger.debug('No active partner for transaction', { + transactionId: transaction.transactionId, + partnerId: transaction.partnerId, + }); + throw new Error('No active partner found for transaction'); + } + + // Calculate commission + const commissionRate = this.getCommissionRate(partner, transaction.category, transaction.type); + const commissionAmount = Math.floor(transaction.amount * commissionRate); + + // Record transaction + const revenueTransaction: RevenueTransaction = { + id: this.generateId(), + partnerId: partner.id, + transactionId: transaction.transactionId, + type: transaction.type, + category: transaction.category, + amount: transaction.amount, + commissionRate, + commissionAmount, + metadata: transaction.metadata, + processedAt: new Date(), + }; + + await this.kv.put(`transaction:${revenueTransaction.id}`, JSON.stringify(revenueTransaction)); + await this.kv.put(`transaction:original:${transaction.transactionId}`, revenueTransaction.id); + + logger.info('Revenue transaction recorded', { + transactionId: revenueTransaction.id, + partnerId: partner.id, + amount: transaction.amount, + commission: commissionAmount, + }); + + return revenueTransaction; + } + + /** + * Get partner statistics for a period + */ + async getPartnerStats(partnerId: string, startDate: Date, endDate: Date): Promise { + const transactions = await this.getPartnerTransactions(partnerId, startDate, endDate); + + const stats: RevenueStats = { + partnerId, + period: { start: startDate, end: endDate }, + totalRevenue: 0, + totalCommission: 0, + transactionCount: transactions.length, + byCategory: {}, + byType: {}, + }; + + for (const transaction of transactions) { + stats.totalRevenue += transaction.amount; + stats.totalCommission += transaction.commissionAmount; + + // By category + if (transaction.category && stats.byCategory) { + if (!stats.byCategory[transaction.category]) { + stats.byCategory[transaction.category] = { revenue: 0, commission: 0, count: 0 }; + } + const categoryStats = stats.byCategory[transaction.category]; + if (categoryStats) { + categoryStats.revenue += transaction.amount; + categoryStats.commission += transaction.commissionAmount; + categoryStats.count += 1; + } + } + + // By type + if (stats.byType) { + if (!stats.byType[transaction.type]) { + stats.byType[transaction.type] = { revenue: 0, commission: 0, count: 0 }; + } + const typeStats = stats.byType[transaction.type]; + if (typeStats) { + typeStats.revenue += transaction.amount; + typeStats.commission += transaction.commissionAmount; + typeStats.count += 1; + } + } + } + + return stats; + } + + /** + * Create a payout for a partner + */ + async createPayout(partnerId: string, startDate: Date, endDate: Date): Promise { + const stats = await this.getPartnerStats(partnerId, startDate, endDate); + + // Check minimum payout amount + if (this.config.minPayoutAmount && stats.totalCommission < this.config.minPayoutAmount) { + throw new Error( + `Commission amount ${stats.totalCommission} is below minimum payout amount ${this.config.minPayoutAmount}`, + ); + } + + const payout: RevenuePayout = { + id: this.generateId(), + partnerId, + periodStart: startDate, + periodEnd: endDate, + totalRevenue: stats.totalRevenue, + totalCommission: stats.totalCommission, + transactionCount: stats.transactionCount, + status: this.config.requireApproval ? 'pending' : 'processing', + createdAt: new Date(), + updatedAt: new Date(), + }; + + await this.kv.put(`payout:${payout.id}`, JSON.stringify(payout)); + + logger.info('Payout created', { + payoutId: payout.id, + partnerId, + amount: stats.totalCommission, + status: payout.status, + }); + + return payout; + } + + /** + * Update payout status + */ + async updatePayoutStatus( + payoutId: string, + status: RevenuePayout['status'], + details?: { + paymentMethod?: string; + paymentDetails?: Record; + notes?: string; + }, + ): Promise { + const payout = await this.getPayout(payoutId); + if (!payout) { + throw new Error(`Payout ${payoutId} not found`); + } + + const updatedPayout: RevenuePayout = { + ...payout, + status, + paymentMethod: details?.paymentMethod || payout.paymentMethod, + paymentDetails: details?.paymentDetails || payout.paymentDetails, + notes: details?.notes || payout.notes, + processedAt: status === 'completed' ? new Date() : payout.processedAt, + updatedAt: new Date(), + }; + + await this.kv.put(`payout:${payoutId}`, JSON.stringify(updatedPayout)); + + logger.info('Payout status updated', { + payoutId, + status, + previousStatus: payout.status, + }); + + return updatedPayout; + } + + /** + * Process pending payouts automatically + */ + async processPayouts( + paymentHandler?: (payout: RevenuePayout) => Promise<{ + success: boolean; + method?: string; + details?: Record; + error?: string; + }>, + ): Promise { + if (!this.config.autoProcessPayouts) { + logger.debug('Auto-process payouts is disabled'); + return; + } + + const pendingPayouts = await this.getPendingPayouts(); + + for (const payout of pendingPayouts) { + try { + if (paymentHandler) { + const result = await paymentHandler(payout); + + if (result.success) { + await this.updatePayoutStatus(payout.id, 'completed', { + paymentMethod: result.method, + paymentDetails: result.details, + }); + } else { + await this.updatePayoutStatus(payout.id, 'failed', { + notes: result.error || 'Payment processing failed', + }); + } + } else { + // Mark as processing if no handler provided + await this.updatePayoutStatus(payout.id, 'processing'); + } + } catch (error) { + logger.error('Failed to process payout', { + payoutId: payout.id, + error: error instanceof Error ? error.message : 'Unknown error', + }); + + await this.updatePayoutStatus(payout.id, 'failed', { + notes: error instanceof Error ? error.message : 'Processing error', + }); + } + } + } + + /** + * Get partner by ID + */ + async getPartner(partnerId: string): Promise { + const data = await this.kv.get(`partner:${partnerId}`); + if (!data) return null; + + const partner = JSON.parse(data); + // Convert date strings back to Date objects + // eslint-disable-next-line db-mapping/use-field-mapper + return { + ...partner, + agreementStartDate: new Date(partner.agreementStartDate), + agreementEndDate: partner.agreementEndDate ? new Date(partner.agreementEndDate) : undefined, + createdAt: new Date(partner.createdAt), + updatedAt: new Date(partner.updatedAt), + }; + } + + /** + * Get partner by external ID + */ + async getPartnerByExternalId(externalId: string): Promise { + const partnerId = await this.kv.get(`partner:external:${externalId}`); + if (!partnerId) return null; + + return await this.getPartner(partnerId); + } + + /** + * Get partner transactions for a period + */ + private async getPartnerTransactions( + partnerId: string, + startDate: Date, + endDate: Date, + ): Promise { + // This is a simplified implementation + // In production, you'd want to use proper database queries + const transactions: RevenueTransaction[] = []; + + // For now, scan all stored transactions (inefficient, but works for demo/tests) + // In production, use proper indexing and queries + const listResult = await this.kv.list({ prefix: 'transaction:' }); + + for (const { name: key } of listResult.keys) { + if (!key.startsWith('transaction:') || key.includes(':original:')) continue; + + const data = await this.kv.get(key); + if (data) { + const transaction: RevenueTransaction = JSON.parse(data); + if ( + transaction.partnerId === partnerId && + new Date(transaction.processedAt) >= startDate && + new Date(transaction.processedAt) <= endDate + ) { + transactions.push(transaction); + } + } + } + + logger.debug('Getting partner transactions', { + partnerId, + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + count: transactions.length, + }); + + return transactions; + } + + /** + * Get pending payouts + */ + private async getPendingPayouts(): Promise { + // This would need proper implementation based on storage adapter + logger.debug('Getting pending payouts'); + return []; + } + + /** + * Get payout by ID + */ + private async getPayout(payoutId: string): Promise { + const data = await this.kv.get(`payout:${payoutId}`); + if (!data) return null; + + const payout = JSON.parse(data); + // Convert date strings back to Date objects + // eslint-disable-next-line db-mapping/use-field-mapper + return { + ...payout, + periodStart: new Date(payout.periodStart), + periodEnd: new Date(payout.periodEnd), + processedAt: payout.processedAt ? new Date(payout.processedAt) : undefined, + createdAt: new Date(payout.createdAt), + updatedAt: new Date(payout.updatedAt), + }; + } + + /** + * Get commission rate for a transaction + */ + private getCommissionRate(partner: RevenuePartner, category?: string, _type?: string): number { + // Check custom rates first + if (category && partner.customRates && category in partner.customRates) { + const rate = partner.customRates[category]; + if (rate !== undefined) return rate; + } + + // Check category rates in config + if (category && this.config.categoryRates && category in this.config.categoryRates) { + const rate = this.config.categoryRates[category]; + if (rate !== undefined) return rate; + } + + // Check tier rates + if (partner.tier && this.config.tierRates && partner.tier in this.config.tierRates) { + const rate = this.config.tierRates[partner.tier]; + if (rate !== undefined) return rate; + } + + // Use partner default rate + return partner.commissionRate; + } + + /** + * Generate unique ID + */ + private generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + } +}