From 7c453e19bea9a6997f05f46eeafe248537f17617 Mon Sep 17 00:00:00 2001 From: Eromosele0110 Date: Thu, 25 Jun 2026 00:44:00 +0100 Subject: [PATCH] feat: implement partner revenue sharing with automated splits (#556) --- backend/services/billing/index.ts | 13 +- backend/services/billing/interfaces.ts | 18 + backend/services/billing/partnerService.ts | 152 ++++++ src/navigation/AppNavigator.tsx | 10 +- src/navigation/types.ts | 1 + src/screens/PartnerDashboardScreen.tsx | 525 +++++++++++++++++++++ src/services/partnerService.ts | 284 +++++++++++ src/store/index.ts | 1 + src/store/partnerStore.ts | 307 ++++++++++++ src/types/partner.ts | 111 +++++ 10 files changed, 1419 insertions(+), 3 deletions(-) create mode 100644 backend/services/billing/partnerService.ts create mode 100644 src/screens/PartnerDashboardScreen.tsx create mode 100644 src/services/partnerService.ts create mode 100644 src/store/partnerStore.ts create mode 100644 src/types/partner.ts diff --git a/backend/services/billing/index.ts b/backend/services/billing/index.ts index 4cf7ccc2..24613188 100644 --- a/backend/services/billing/index.ts +++ b/backend/services/billing/index.ts @@ -29,5 +29,16 @@ export type { StreamExportOptions, ReconciliationResult, } from './accountingExportService'; -export type { IMeteringService, IPricingService, ITaxService, IDunningService, IAccountingExportService } from './interfaces'; +export { + BackendPartnerService, +} from './partnerService'; +export type { SplitConfiguration, PartnerPayoutSchedule } from '../../../src/types/partner'; +export type { + IMeteringService, + IPricingService, + ITaxService, + IDunningService, + IAccountingExportService, + IPartnerService, +} from './interfaces'; export { BillingError, BillingErrorCode } from './errors'; diff --git a/backend/services/billing/interfaces.ts b/backend/services/billing/interfaces.ts index b65a3a3a..55a3deca 100644 --- a/backend/services/billing/interfaces.ts +++ b/backend/services/billing/interfaces.ts @@ -20,6 +20,7 @@ import { ReconciliationResult, TransactionType, } from './accountingExportService'; +import { SplitConfiguration, PartnerPayoutSchedule } from '../../../src/types/partner'; export interface IMeteringService { recordUsage(metric: UsageMetric): Promise; @@ -62,3 +63,20 @@ export interface IAccountingExportService { expected: Array<{ id: string; amount: number; transactionType: TransactionType }> ): ReconciliationResult; } + +export interface IPartnerService { + executeSplitAtSettlement(input: { + splitConfiguration: SplitConfiguration; + transactionId: string; + grossAmount: number; + }): SplitExecution; + shouldSchedulePayout(config: SplitConfiguration, lastPayoutDate: Date | null): { + shouldProcess: boolean; + nextScheduledDate: Date; + reason: string; + }; + aggregatePendingPayouts( + configurations: SplitConfiguration[], + grossAmount: number + ): Map; +} diff --git a/backend/services/billing/partnerService.ts b/backend/services/billing/partnerService.ts new file mode 100644 index 00000000..dd8e8637 --- /dev/null +++ b/backend/services/billing/partnerService.ts @@ -0,0 +1,152 @@ +import type { SplitConfiguration, SplitExecution, PartnerPayoutSchedule } from '../../src/types/partner'; +import { SplitEngine, type SplitResult } from '../../src/services/partnerService'; + +export interface PartnerSplitExecutionInput { + splitConfiguration: SplitConfiguration; + transactionId: string; + grossAmount: number; +} + +export interface PayoutSchedulingResult { + shouldProcess: boolean; + nextScheduledDate: Date; + reason: string; +} + +export class BackendPartnerService { + static executeSplitAtSettlement(input: PartnerSplitExecutionInput): SplitExecution { + const { splitConfiguration, transactionId, grossAmount } = input; + + const result: SplitResult = SplitEngine.calculateSplit(splitConfiguration, grossAmount); + + return { + id: `exec-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`, + splitConfigurationId: splitConfiguration.id, + subscriptionId: splitConfiguration.subscriptionId, + transactionId, + grossAmount, + splits: result.splits.map((s) => ({ + partnerId: s.partnerId, + amount: s.amount, + percentage: s.percentage, + })), + platformRevenue: result.platformRevenue, + executedAt: new Date(), + status: 'completed', + }; + } + + static shouldSchedulePayout( + config: SplitConfiguration, + lastPayoutDate: Date | null + ): PayoutSchedulingResult { + const now = new Date(); + + if (!config.isActive) { + return { + shouldProcess: false, + nextScheduledDate: now, + reason: 'Configuration is not active', + }; + } + + switch (config.payoutSchedule) { + case 'instant': + return { + shouldProcess: true, + nextScheduledDate: now, + reason: 'Instant payouts are processed immediately', + }; + + case 'daily': { + if (!lastPayoutDate) { + return { + shouldProcess: true, + nextScheduledDate: now, + reason: 'No previous payout found', + }; + } + const diffMs = now.getTime() - lastPayoutDate.getTime(); + const oneDayMs = 24 * 60 * 60 * 1000; + if (diffMs >= oneDayMs) { + return { + shouldProcess: true, + nextScheduledDate: new Date(lastPayoutDate.getTime() + oneDayMs), + reason: 'Daily threshold reached', + }; + } + return { + shouldProcess: false, + nextScheduledDate: new Date(lastPayoutDate.getTime() + oneDayMs), + reason: 'Daily threshold not yet reached', + }; + } + + case 'weekly': { + if (!lastPayoutDate) { + return { + shouldProcess: true, + nextScheduledDate: now, + reason: 'No previous payout found', + }; + } + const diffMs = now.getTime() - lastPayoutDate.getTime(); + const oneWeekMs = 7 * 24 * 60 * 60 * 1000; + if (diffMs >= oneWeekMs) { + return { + shouldProcess: true, + nextScheduledDate: new Date(lastPayoutDate.getTime() + oneWeekMs), + reason: 'Weekly threshold reached', + }; + } + return { + shouldProcess: false, + nextScheduledDate: new Date(lastPayoutDate.getTime() + oneWeekMs), + reason: 'Weekly threshold not yet reached', + }; + } + + case 'threshold': { + const threshold = config.minPayoutThreshold ?? 0; + if (threshold <= 0) { + return { + shouldProcess: true, + nextScheduledDate: now, + reason: 'No minimum threshold set', + }; + } + return { + shouldProcess: false, + nextScheduledDate: now, + reason: `Pending balance must meet minimum threshold of ${threshold}`, + }; + } + + default: + return { + shouldProcess: false, + nextScheduledDate: now, + reason: 'Unknown payout schedule', + }; + } + } + + static aggregatePendingPayouts( + configurations: SplitConfiguration[], + grossAmount: number + ): Map { + const pendingByPartner = new Map(); + + for (const config of configurations) { + if (!config.isActive) continue; + + const result = SplitEngine.calculateSplit(config, grossAmount); + for (const split of result.splits) { + const current = pendingByPartner.get(split.partnerId) ?? 0; + pendingByPartner.set(split.partnerId, current + split.amount); + } + } + + return pendingByPartner; + } +} diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 0f32b2ee..fa67fb18 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -65,6 +65,7 @@ const SandboxDashboardScreen = lazyScreen(() => import('../screens/SandboxDashbo const ApiKeyManagementScreen = lazyScreen(() => import('../screens/ApiKeyManagementScreen')); const DocumentationPortalScreen = lazyScreen(() => import('../screens/DocumentationPortalScreen')); const IntegrationGuidesScreen = lazyScreen(() => import('../screens/IntegrationGuidesScreen')); +const PartnerDashboardScreen = lazyScreen(() => import('../screens/PartnerDashboardScreen')); const PerformanceDashboardScreen = lazyScreen( () => import('../screens/PerformanceDashboardScreen') ); @@ -197,9 +198,14 @@ const HomeStack = () => ( - + + ); const SettingsStack = () => ( diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 10bf40b0..fb323968 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -48,6 +48,7 @@ export type RootStackParamList = { ChangePlan: { subscriptionId: string }; PaymentMethods: undefined; AnalyticsDashboard: undefined; + PartnerDashboard: undefined; NotFound: { reason?: string }; }; diff --git a/src/screens/PartnerDashboardScreen.tsx b/src/screens/PartnerDashboardScreen.tsx new file mode 100644 index 00000000..82ea1efc --- /dev/null +++ b/src/screens/PartnerDashboardScreen.tsx @@ -0,0 +1,525 @@ +import React, { useState, useMemo, useCallback } from 'react'; +import { + SafeAreaView, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, + Alert, +} from 'react-native'; +import { Card } from '../components/common/Card'; +import { Button } from '../components/common/Button'; +import { FormScreen } from '../components/common/ScreenTemplates'; +import { spacing, typography, borderRadius } from '../utils/constants'; +import { useThemeColors } from '../hooks/useThemeColors'; +import { usePartnerStore } from '../store/partnerStore'; +import type { Partner, SplitType, PartnerPayoutSchedule } from '../types/partner'; +import { SplitEngine } from '../services/partnerService'; + +type Tab = 'partners' | 'splits' | 'payouts'; + +const PartnerDashboardScreen: React.FC = () => { + const colors = useThemeColors(); + const styles = useMemo(() => createStyles(colors), [colors]); + const { + partners, + splitConfigurations, + payoutRecords, + getPartnerEarnings, + getSubscriptionSplits, + } = usePartnerStore(); + + const [activeTab, setActiveTab] = useState('partners'); + const [selectedPartnerId, setSelectedPartnerId] = useState(null); + + const partnerEarnings = useMemo(() => { + if (!selectedPartnerId) return null; + return getPartnerEarnings(selectedPartnerId); + }, [selectedPartnerId, getPartnerEarnings, payoutRecords]); + + const totalPendingPayouts = useMemo( + () => partners.reduce((sum, p) => sum + (getPartnerEarnings(p.id).pendingPayouts), 0), + [partners, getPartnerEarnings] + ); + + const totalCompletedPayouts = useMemo( + () => partners.reduce((sum, p) => sum + (getPartnerEarnings(p.id).completedPayouts), 0), + [partners, getPartnerEarnings] + ); + + const renderPartnersTab = () => ( + + Partners + {partners.length === 0 ? ( + + No partners enrolled yet. + + ) : ( + partners.map((partner) => { + const earnings = getPartnerEarnings(partner.id); + const isSelected = selectedPartnerId === partner.id; + return ( + setSelectedPartnerId(isSelected ? null : partner.id)}> + + + + {partner.name} + + {partner.company ?? 'Individual'} · {partner.email} + + + + {partner.status} + + + + + Total Earnings + ${earnings.totalEarnings.toFixed(2)} + + + Pending + + ${earnings.pendingPayouts.toFixed(2)} + + + + Paid Out + + ${earnings.completedPayouts.toFixed(2)} + + + + {isSelected && ( + + Onboarded + {new Date(partner.onboardedAt).toLocaleDateString()} + {partner.paymentAddress && ( + <> + Payment Address + {partner.paymentAddress} + + )} + {partner.taxId && ( + <> + Tax ID + {partner.taxId} + + )} + + )} + + + ); + }) + )} + + ); + + const renderSplitsTab = () => { + const subscriptionIds = [...new Set(splitConfigurations.map((c) => c.subscriptionId))]; + + return ( + + Split Configurations + {subscriptionIds.length === 0 ? ( + + No split configurations found. + + ) : ( + subscriptionIds.map((subId) => { + const configs = getSubscriptionSplits(subId); + return ( + + Subscription: {subId} + {configs.map((config) => ( + + + + {config.splitType.toUpperCase()} + + + Partner: {config.partnerId} + + + Schedule: {config.payoutSchedule} + + {config.percentage !== undefined && ( + + Split: {config.percentage}% + + )} + {config.fixedAmount !== undefined && ( + + Split: {config.currency} {config.fixedAmount.toFixed(2)} + + )} + + + + ))} + + ); + }) + )} + + ); + }; + + const renderPayoutsTab = () => ( + + Recent Payouts + {payoutRecords.length === 0 ? ( + + No payout records yet. + + ) : ( + payoutRecords + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, 20) + .map((payout) => ( + + + {payout.partnerId} + + {payout.status} + + + + {payout.currency} {payout.netAmount.toFixed(2)} + + + Gross: {payout.currency} {payout.grossAmount.toFixed(2)} · Fee: {payout.currency} {payout.platformFee.toFixed(2)} + + + {new Date(payout.createdAt).toLocaleString()} + + + )) + )} + + ); + + const getStatusColor = (status: PartnerStatus) => { + switch (status) { + case 'verified': + return colors.success; + case 'pending': + return colors.warning; + case 'suspended': + return colors.error; + case 'rejected': + return colors.textSecondary; + default: + return colors.textSecondary; + } + }; + + const getPayoutStatusColor = (status: string) => { + switch (status) { + case 'completed': + return colors.success; + case 'pending': + return colors.warning; + case 'processing': + return colors.primary; + case 'failed': + return colors.error; + default: + return colors.textSecondary; + } + }; + + return ( + + + + Partner Dashboard + + Manage collaborators, revenue splits, and automated payouts. + + + + + + Partners + + {partners.length} + + + + Pending Payouts + + ${totalPendingPayouts.toFixed(2)} + + + + Completed + + ${totalCompletedPayouts.toFixed(2)} + + + + + + {(['partners', 'splits', 'payouts'] as Tab[]).map((tab) => ( + setActiveTab(tab)}> + + {tab.charAt(0).toUpperCase() + tab.slice(1)} + + + ))} + + + {activeTab === 'partners' && renderPartnersTab()} + {activeTab === 'splits' && renderSplitsTab()} + {activeTab === 'payouts' && renderPayoutsTab()} + + + ); +}; + +function createStyles(colors: ReturnType) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.primary, + }, + scrollView: { + flex: 1, + }, + header: { + padding: spacing.lg, + paddingBottom: spacing.md, + }, + title: { + ...typography.h1, + color: colors.text.primary, + marginBottom: spacing.xs, + }, + subtitle: { + ...typography.body, + color: colors.textSecondary, + }, + summaryRow: { + flexDirection: 'row', + paddingHorizontal: spacing.lg, + marginBottom: spacing.md, + gap: spacing.md, + }, + summaryCard: { + flex: 1, + alignItems: 'center', + }, + summaryLabel: { + ...typography.caption, + color: colors.textSecondary, + marginBottom: spacing.xs, + }, + summaryValue: { + ...typography.h2, + fontWeight: '700', + }, + tabRow: { + flexDirection: 'row', + paddingHorizontal: spacing.lg, + marginBottom: spacing.md, + gap: spacing.sm, + }, + tabBtn: { + flex: 1, + paddingVertical: spacing.sm, + borderRadius: borderRadius.md, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border.default, + alignItems: 'center', + }, + tabBtnActive: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + tabBtnText: { + ...typography.body, + color: colors.text.primary, + }, + tabBtnTextActive: { + color: colors.text.inverse, + fontWeight: '600', + }, + section: { + paddingHorizontal: spacing.lg, + marginBottom: spacing.xl, + }, + sectionTitle: { + ...typography.h3, + color: colors.text.primary, + marginBottom: spacing.md, + }, + emptyCard: { + padding: spacing.lg, + alignItems: 'center', + }, + emptyText: { + ...typography.body, + color: colors.textSecondary, + textAlign: 'center', + }, + partnerCard: { + marginBottom: spacing.md, + }, + partnerCardSelected: { + borderColor: colors.primary, + borderWidth: 2, + }, + partnerHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + gap: spacing.md, + }, + partnerInfo: { + flex: 1, + }, + partnerName: { + ...typography.body, + color: colors.text.primary, + fontWeight: '700', + marginBottom: spacing.xs, + }, + partnerMeta: { + ...typography.caption, + color: colors.textSecondary, + }, + statusBadge: { + paddingVertical: 4, + paddingHorizontal: spacing.sm, + borderRadius: borderRadius.full, + }, + statusText: { + ...typography.caption, + color: colors.text.inverse, + fontWeight: '700', + textTransform: 'uppercase', + }, + partnerMetrics: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: spacing.md, + paddingTop: spacing.md, + borderTopWidth: 1, + borderTopColor: colors.border.default, + }, + metric: { + alignItems: 'center', + }, + metricLabel: { + ...typography.caption, + color: colors.textSecondary, + marginBottom: spacing.xs, + }, + metricValue: { + ...typography.body, + color: colors.text.primary, + fontWeight: '600', + }, + partnerDetail: { + marginTop: spacing.md, + paddingTop: spacing.md, + borderTopWidth: 1, + borderTopColor: colors.border.default, + gap: spacing.xs, + }, + detailLabel: { + ...typography.caption, + color: colors.textSecondary, + fontWeight: '600', + }, + detailValue: { + ...typography.body, + color: colors.text.primary, + }, + configCard: { + marginBottom: spacing.md, + }, + configTitle: { + ...typography.body, + color: colors.text.primary, + fontWeight: '700', + marginBottom: spacing.sm, + }, + configRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.border.default, + }, + configInfo: { + flex: 1, + }, + configType: { + ...typography.body, + color: colors.text.primary, + fontWeight: '600', + }, + configPartner: { + ...typography.caption, + color: colors.textSecondary, + marginTop: 2, + }, + configSchedule: { + ...typography.caption, + color: colors.textSecondary, + marginTop: 2, + }, + configValue: { + ...typography.caption, + color: colors.primary, + fontWeight: '600', + marginTop: 2, + }, + activeIndicator: { + width: 12, + height: 12, + borderRadius: 6, + backgroundColor: colors.textSecondary, + }, + activeIndicatorOn: { + backgroundColor: colors.success, + }, + payoutCard: { + marginBottom: spacing.md, + }, + payoutHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.sm, + }, + payoutPartner: { + ...typography.body, + color: colors.text.primary, + fontWeight: '700', + }, + payoutAmount: { + ...typography.h3, + color: colors.text.primary, + marginBottom: spacing.xs, + }, + payoutMeta: { + ...typography.caption, + color: colors.textSecondary, + marginTop: spacing.xs, + }, + }); +} + +export default PartnerDashboardScreen; diff --git a/src/services/partnerService.ts b/src/services/partnerService.ts new file mode 100644 index 00000000..2aca1886 --- /dev/null +++ b/src/services/partnerService.ts @@ -0,0 +1,284 @@ +import type { + SplitConfiguration, + SplitExecution, + SplitTier, + Partner, + PartnerEarnings, + PayoutRecord, + SplitType, +} from '../types/partner'; +import { PartnerStatus, PartnerPayoutSchedule } from '../types/partner'; + +export interface SplitResult { + splits: Array<{ partnerId: string; amount: number; percentage: number }>; + platformRevenue: number; + totalSplit: number; +} + +export interface SplitValidationResult { + isValid: boolean; + errors: string[]; +} + +export class SplitEngine { + static calculateSplit(config: SplitConfiguration, grossAmount: number): SplitResult { + if (grossAmount <= 0) { + return { splits: [], platformRevenue: grossAmount, totalSplit: 0 }; + } + + switch (config.splitType) { + case 'percentage': + return this.calculatePercentageSplit(config, grossAmount); + case 'fixed_amount': + return this.calculateFixedAmountSplit(config, grossAmount); + case 'tiered_waterfall': + return this.calculateTieredWaterfall(config, grossAmount); + default: + return { splits: [], platformRevenue: grossAmount, totalSplit: 0 }; + } + } + + private static calculatePercentageSplit( + config: SplitConfiguration, + grossAmount: number + ): SplitResult { + const percentage = config.percentage ?? 0; + const clampedPercentage = Math.min(100, Math.max(0, percentage)); + const partnerAmount = grossAmount * (clampedPercentage / 100); + const platformRevenue = grossAmount - partnerAmount; + + return { + splits: [ + { + partnerId: config.partnerId, + amount: partnerAmount, + percentage: clampedPercentage, + }, + ], + platformRevenue, + totalSplit: partnerAmount, + }; + } + + private static calculateFixedAmountSplit( + config: SplitConfiguration, + grossAmount: number + ): SplitResult { + const fixedAmount = Math.min(config.fixedAmount ?? 0, grossAmount); + const platformRevenue = grossAmount - fixedAmount; + + return { + splits: [ + { + partnerId: config.partnerId, + amount: fixedAmount, + percentage: grossAmount > 0 ? (fixedAmount / grossAmount) * 100 : 0, + }, + ], + platformRevenue, + totalSplit: fixedAmount, + }; + } + + private static calculateTieredWaterfall( + config: SplitConfiguration, + grossAmount: number + ): SplitResult { + const tiers = config.tiers ?? []; + if (tiers.length === 0) { + return { splits: [], platformRevenue: grossAmount, totalSplit: 0 }; + } + + const sortedTiers = [...tiers].sort((a, b) => a.threshold - b.threshold); + let remaining = grossAmount; + const splits: Array<{ partnerId: string; amount: number; percentage: number }> = []; + + for (const tier of sortedTiers) { + if (remaining <= 0) break; + + const tierAmount = Math.min(tier.fixedAmount ?? remaining * (tier.splitPercentage / 100), remaining); + if (tierAmount <= 0) continue; + + splits.push({ + partnerId: config.partnerId, + amount: tierAmount, + percentage: tier.splitPercentage, + }); + remaining -= tierAmount; + } + + const totalSplit = splits.reduce((sum, s) => sum + s.amount, 0); + return { + splits, + platformRevenue: grossAmount - totalSplit, + totalSplit, + }; + } + + static executeWaterfall( + configurations: SplitConfiguration[], + grossAmount: number + ): SplitResult { + const sorted = [...configurations].sort((a, b) => { + const priorityA = a.tiers?.[0]?.priority ?? 0; + const priorityB = b.tiers?.[0]?.priority ?? 0; + return priorityA - priorityB; + }); + + let remaining = grossAmount; + const allSplits: Array<{ partnerId: string; amount: number; percentage: number }> = []; + + for (const config of sorted) { + if (remaining <= 0) break; + const result = this.calculateSplit(config, remaining); + allSplits.push(...result.splits); + remaining -= result.totalSplit; + } + + const totalSplit = allSplits.reduce((sum, s) => sum + s.amount, 0); + return { + splits: allSplits, + platformRevenue: grossAmount - totalSplit, + totalSplit, + }; + } + + static validateSplitConfig(config: Partial): SplitValidationResult { + const errors: string[] = []; + + if (!config.splitType) { + errors.push('Split type is required'); + } + + if (!config.partnerId) { + errors.push('Partner ID is required'); + } + + if (!config.subscriptionId) { + errors.push('Subscription ID is required'); + } + + if (config.splitType === 'percentage') { + if (config.percentage === undefined || config.percentage === null) { + errors.push('Percentage is required for percentage splits'); + } else if (config.percentage < 0 || config.percentage > 100) { + errors.push('Percentage must be between 0 and 100'); + } + } + + if (config.splitType === 'fixed_amount') { + if (config.fixedAmount === undefined || config.fixedAmount === null) { + errors.push('Fixed amount is required for fixed amount splits'); + } else if (config.fixedAmount < 0) { + errors.push('Fixed amount must be non-negative'); + } + } + + if (config.splitType === 'tiered_waterfall') { + if (!config.tiers || config.tiers.length === 0) { + errors.push('At least one tier is required for tiered waterfall splits'); + } else { + const totalPercentage = config.tiers.reduce((sum, t) => sum + t.splitPercentage, 0); + if (totalPercentage > 100) { + errors.push('Total tier percentage cannot exceed 100'); + } + const hasDuplicatePriorities = new Set(config.tiers.map((t) => t.priority)).size !== config.tiers.length; + if (hasDuplicatePriorities) { + errors.push('Tier priorities must be unique'); + } + } + } + + if (config.payoutSchedule && !Object.values(PartnerPayoutSchedule).includes(config.payoutSchedule)) { + errors.push('Invalid payout schedule'); + } + + return { + isValid: errors.length === 0, + errors, + }; + } +} + +export class PartnerService { + static calculatePartnerEarnings( + payouts: PayoutRecord[], + partnerId: string, + startDate?: Date, + endDate?: Date + ): PartnerEarnings { + const start = startDate ?? new Date(0); + const end = endDate ?? new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); + + const filtered = payouts.filter((p) => { + if (p.partnerId !== partnerId) return false; + const createdAt = new Date(p.createdAt); + return createdAt >= start && createdAt <= end; + }); + + const totalEarnings = filtered.reduce((sum, p) => sum + p.netAmount, 0); + const pendingPayouts = payouts + .filter((p) => p.partnerId === partnerId && p.status === 'pending') + .reduce((sum, p) => sum + p.netAmount, 0); + const completedPayouts = filtered + .filter((p) => p.status === 'completed') + .reduce((sum, p) => sum + p.netAmount, 0); + + const bySubscription: Record = {}; + filtered.forEach((p) => { + bySubscription[p.subscriptionId] = (bySubscription[p.subscriptionId] || 0) + p.netAmount; + }); + + return { + partnerId, + totalEarnings, + pendingPayouts, + completedPayouts, + currency: filtered[0]?.currency ?? 'USD', + periodStart: start, + periodEnd: end, + bySubscription, + }; + } + + static shouldProcessPayout( + config: SplitConfiguration, + lastPayoutDate: Date | null, + now: Date + ): boolean { + if (!config.isActive) return false; + + switch (config.payoutSchedule) { + case 'instant': + return true; + case 'daily': { + if (!lastPayoutDate) return true; + const diff = now.getTime() - lastPayoutDate.getTime(); + return diff >= 24 * 60 * 60 * 1000; + } + case 'weekly': { + if (!lastPayoutDate) return true; + const diff = now.getTime() - lastPayoutDate.getTime(); + return diff >= 7 * 24 * 60 * 60 * 1000; + } + case 'threshold': { + const threshold = config.minPayoutThreshold ?? 0; + // Threshold check should be done at call site with actual pending balance + return true; + } + default: + return false; + } + } + + static mergeTierConfigs( + existing: SplitTier[], + incoming: SplitTier[] + ): SplitTier[] { + const byId = new Map(existing.map((t) => [t.id, t])); + for (const tier of incoming) { + byId.set(tier.id, tier); + } + return Array.from(byId.values()).sort((a, b) => a.priority - b.priority); + } +} diff --git a/src/store/index.ts b/src/store/index.ts index 8d98cc10..b4e42544 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -9,6 +9,7 @@ export { useCommunityStore } from './communityStore'; export { useFraudStore } from './fraudStore'; export { useGroupStore } from './groupStore'; export { useTaxStore } from './taxStore'; +export { usePartnerStore } from './partnerStore'; export { useSupportStore } from './supportStore'; export { useAuthStore } from './authStore'; export { useCancellationStore } from './cancellationStore'; diff --git a/src/store/partnerStore.ts b/src/store/partnerStore.ts new file mode 100644 index 00000000..b24d0c59 --- /dev/null +++ b/src/store/partnerStore.ts @@ -0,0 +1,307 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import { debouncedAsyncStorageAdapter } from '../utils/storage'; +import type { + Partner, + SplitConfiguration, + PayoutRecord, + SplitExecution, + PartnerEarnings, + PartnerStatus, + SplitType, + PartnerPayoutSchedule, +} from '../types/partner'; +import { errorHandler, AppError } from '../services/errorHandler'; +import { partnerService } from '../services/partnerService'; + +const STORAGE_KEY = 'subtrackr-partners'; + +const generateUniqueId = (): string => { + const timestamp = Date.now().toString(36); + const randomComponent = Math.random().toString(36).substring(2, 8); + return `${timestamp}-${randomComponent}`; +}; + +interface PartnerState { + partners: Partner[]; + splitConfigurations: SplitConfiguration[]; + payoutRecords: PayoutRecord[]; + splitExecutions: SplitExecution[]; + isLoading: boolean; + error: AppError | null; + + onboardPartner: (data: Omit) => Promise; + updatePartner: (id: string, data: Partial) => Promise; + verifyPartner: (id: string) => Promise; + rejectPartner: (id: string, reason: string) => Promise; + suspendPartner: (id: string) => Promise; + reactivatePartner: (id: string) => Promise; + configureSplit: (data: Omit) => Promise; + updateSplitConfiguration: (id: string, data: Partial) => Promise; + executeSplit: (splitConfigurationId: string, transactionId: string, grossAmount: number) => Promise; + recordPayout: (data: Omit) => Promise; + getPartnerEarnings: (partnerId: string, startDate?: Date, endDate?: Date) => PartnerEarnings; + getPartnerPayouts: (partnerId: string) => PayoutRecord[]; + getSubscriptionSplits: (subscriptionId: string) => SplitConfiguration[]; + deleteSplitConfiguration: (id: string) => Promise; +} + +export const usePartnerStore = create()( + persist( + (set, get) => ({ + partners: [], + splitConfigurations: [], + payoutRecords: [], + splitExecutions: [], + isLoading: false, + error: null, + + onboardPartner: async (data) => { + set({ isLoading: true, error: null }); + try { + const partner: Partner = { + ...data, + id: generateUniqueId(), + onboardedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + set((state) => ({ + partners: [...state.partners, partner], + isLoading: false, + })); + return partner; + } catch (error) { + const appError = errorHandler.handleError(error as Error, { + action: 'onboardPartner', + }); + set({ error: appError, isLoading: false }); + throw appError; + } + }, + + updatePartner: async (id, data) => { + set({ isLoading: true, error: null }); + try { + set((state) => ({ + partners: state.partners.map((p) => + p.id === id ? { ...p, ...data, updatedAt: new Date() } : p + ), + isLoading: false, + })); + } catch (error) { + const appError = errorHandler.handleError(error as Error, { + action: 'updatePartner', + partnerId: id, + }); + set({ error: appError, isLoading: false }); + throw appError; + } + }, + + verifyPartner: async (id) => { + await get().updatePartner(id, { status: 'verified' as PartnerStatus, verifiedAt: new Date() }); + }, + + rejectPartner: async (id, reason) => { + await get().updatePartner(id, { + status: 'rejected' as PartnerStatus, + rejectionReason: reason, + suspendedAt: new Date(), + }); + }, + + suspendPartner: async (id) => { + await get().updatePartner(id, { + status: 'suspended' as PartnerStatus, + suspendedAt: new Date(), + }); + }, + + reactivatePartner: async (id) => { + await get().updatePartner(id, { + status: 'verified' as PartnerStatus, + suspendedAt: undefined, + rejectionReason: undefined, + }); + }, + + configureSplit: async (data) => { + set({ isLoading: true, error: null }); + try { + const validation = partnerService.validateSplitConfig(data); + if (!validation.isValid) { + throw new Error(`Invalid split configuration: ${validation.errors.join(', ')}`); + } + + const config: SplitConfiguration = { + ...data, + id: generateUniqueId(), + createdAt: new Date(), + updatedAt: new Date(), + }; + set((state) => ({ + splitConfigurations: [...state.splitConfigurations, config], + isLoading: false, + })); + return config; + } catch (error) { + const appError = errorHandler.handleError(error as Error, { + action: 'configureSplit', + }); + set({ error: appError, isLoading: false }); + throw appError; + } + }, + + updateSplitConfiguration: async (id, data) => { + set({ isLoading: true, error: null }); + try { + set((state) => ({ + splitConfigurations: state.splitConfigurations.map((c) => + c.id === id ? { ...c, ...data, updatedAt: new Date() } : c + ), + isLoading: false, + })); + } catch (error) { + const appError = errorHandler.handleError(error as Error, { + action: 'updateSplitConfiguration', + splitConfigurationId: id, + }); + set({ error: appError, isLoading: false }); + throw appError; + } + }, + + executeSplit: async (splitConfigurationId, transactionId, grossAmount) => { + set({ isLoading: true, error: null }); + try { + const config = get().splitConfigurations.find((c) => c.id === splitConfigurationId); + if (!config) { + throw new Error('Split configuration not found'); + } + + const result = partnerService.calculateSplit(config, grossAmount); + const execution: SplitExecution = { + id: generateUniqueId(), + splitConfigurationId, + subscriptionId: config.subscriptionId, + transactionId, + grossAmount, + splits: result.splits, + platformRevenue: result.platformRevenue, + executedAt: new Date(), + status: 'completed', + }; + + set((state) => ({ + splitExecutions: [...state.splitExecutions, execution], + isLoading: false, + })); + + return execution; + } catch (error) { + const appError = errorHandler.handleError(error as Error, { + action: 'executeSplit', + splitConfigurationId, + }); + set({ error: appError, isLoading: false }); + throw appError; + } + }, + + recordPayout: async (data) => { + set({ isLoading: true, error: null }); + try { + const record: PayoutRecord = { + ...data, + id: generateUniqueId(), + createdAt: new Date(), + }; + set((state) => ({ + payoutRecords: [...state.payoutRecords, record], + isLoading: false, + })); + return record; + } catch (error) { + const appError = errorHandler.handleError(error as Error, { + action: 'recordPayout', + }); + set({ error: appError, isLoading: false }); + throw appError; + } + }, + + getPartnerEarnings: (partnerId, startDate, endDate) => { + const payouts = get().payoutRecords.filter((p) => p.partnerId === partnerId); + const start = startDate ?? new Date(0); + const end = endDate ?? new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); + + const filtered = payouts.filter((p) => { + const createdAt = new Date(p.createdAt); + return createdAt >= start && createdAt <= end && p.status === 'completed'; + }); + + const totalEarnings = filtered.reduce((sum, p) => sum + p.netAmount, 0); + const pendingPayouts = payouts + .filter((p) => p.status === 'pending') + .reduce((sum, p) => sum + p.netAmount, 0); + const completedPayouts = filtered.reduce((sum, p) => sum + p.netAmount, 0); + + const bySubscription: Record = {}; + filtered.forEach((p) => { + bySubscription[p.subscriptionId] = (bySubscription[p.subscriptionId] || 0) + p.netAmount; + }); + + return { + partnerId, + totalEarnings, + pendingPayouts, + completedPayouts, + currency: filtered[0]?.currency ?? 'USD', + periodStart: start, + periodEnd: end, + bySubscription, + }; + }, + + getPartnerPayouts: (partnerId) => { + return get().payoutRecords + .filter((p) => p.partnerId === partnerId) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + }, + + getSubscriptionSplits: (subscriptionId) => { + return get().splitConfigurations.filter((c) => c.subscriptionId === subscriptionId); + }, + + deleteSplitConfiguration: async (id) => { + set({ isLoading: true, error: null }); + try { + set((state) => ({ + splitConfigurations: state.splitConfigurations.filter((c) => c.id !== id), + isLoading: false, + })); + } catch (error) { + const appError = errorHandler.handleError(error as Error, { + action: 'deleteSplitConfiguration', + splitConfigurationId: id, + }); + set({ error: appError, isLoading: false }); + throw appError; + } + }, + }), + { + name: STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => debouncedAsyncStorageAdapter), + partialize: (state) => ({ + partners: state.partners, + splitConfigurations: state.splitConfigurations, + payoutRecords: state.payoutRecords, + splitExecutions: state.splitExecutions, + }), + } + ) +); diff --git a/src/types/partner.ts b/src/types/partner.ts new file mode 100644 index 00000000..588a7097 --- /dev/null +++ b/src/types/partner.ts @@ -0,0 +1,111 @@ +export enum PartnerStatus { + PENDING = 'pending', + VERIFIED = 'verified', + SUSPENDED = 'suspended', + REJECTED = 'rejected', +} + +export enum SplitType { + PERCENTAGE = 'percentage', + FIXED_AMOUNT = 'fixed_amount', + TIERED_WATERFALL = 'tiered_waterfall', +} + +export enum PartnerPayoutSchedule { + INSTANT = 'instant', + DAILY = 'daily', + WEEKLY = 'weekly', + THRESHOLD = 'threshold', +} + +export interface SplitTier { + id: string; + name: string; + threshold: number; + splitPercentage: number; + fixedAmount?: number; + priority: number; +} + +export interface SplitConfiguration { + id: string; + subscriptionId: string; + partnerId: string; + splitType: SplitType; + payoutSchedule: PartnerPayoutSchedule; + percentage?: number; + fixedAmount?: number; + currency: string; + minPayoutThreshold?: number; + maxPayoutPerPeriod?: number; + tiers?: SplitTier[]; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface PayoutRecord { + id: string; + partnerId: string; + splitConfigurationId: string; + subscriptionId: string; + amount: number; + currency: string; + grossAmount: number; + platformFee: number; + netAmount: number; + status: 'pending' | 'processing' | 'completed' | 'failed'; + transactionHash?: string; + executedAt?: Date; + settledAt?: Date; + periodStart: Date; + periodEnd: Date; + metadata?: Record; + createdAt: Date; +} + +export interface Partner { + id: string; + name: string; + email: string; + company?: string; + status: PartnerStatus; + paymentAddress?: string; + taxId?: string; + contractUrl?: string; + onboardedAt: Date; + verifiedAt?: Date; + suspendedAt?: Date; + rejectionReason?: string; + metadata?: Record; + createdAt: Date; + updatedAt: Date; +} + +export interface SplitExecution { + id: string; + splitConfigurationId: string; + subscriptionId: string; + transactionId: string; + grossAmount: number; + splits: Array<{ + partnerId: string; + amount: number; + percentage: number; + }>; + platformRevenue: number; + executedAt: Date; + status: 'pending' | 'completed' | 'failed'; + error?: string; +} + +export interface PartnerEarnings { + partnerId: string; + totalEarnings: number; + pendingPayouts: number; + completedPayouts: number; + currency: string; + periodStart: Date; + periodEnd: Date; + bySubscription: Record; +}