- Add 100 blog posts covering AI, development, and tech topics - Add .env.example for environment configuration - Add accessibility and lighthouse audit scripts - Remove obsolete SEO reports and temporary files - Remove dev-dist build artifacts and backup files - Remove unused portrait images (moved/consolidated elsewhere) - Update contact form and component improvements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1256 lines
33 KiB
Markdown
1256 lines
33 KiB
Markdown
# Affiliate & Referral Systems
|
||
|
||
**Meta-Description:** Affiliate und Referral Programme implementieren. Tracking Links, Provisionsberechnung und Auszahlungen mit Node.js und Stripe automatisieren.
|
||
|
||
**Keywords:** Affiliate Marketing, Referral Program, Commission Tracking, Partner Program, Referral Links, Payout System, Revenue Share
|
||
|
||
---
|
||
|
||
## Einführung
|
||
|
||
**Affiliate und Referral Systeme** treiben Wachstum durch Empfehlungen. Von **Tracking-Links** über **Commission-Berechnung** bis **automatisierte Payouts** – ein gut implementiertes System skaliert Kundenakquise kosteneffizient. Dieser Guide zeigt Production-ready Implementierungen.
|
||
|
||
---
|
||
|
||
## System Architecture
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ AFFILIATE & REFERRAL SYSTEM │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||
│ │ AFFILIATE │ │ REFERRAL │ │
|
||
│ │ (B2B/Partner) │ │ (Customer) │ │
|
||
│ ├─────────────────┤ ├─────────────────┤ │
|
||
│ │ • Influencers │ │ • Invite friends│ │
|
||
│ │ • Bloggers │ │ • Share links │ │
|
||
│ │ • Review sites │ │ • Earn rewards │ │
|
||
│ │ • Partners │ │ • Both benefit │ │
|
||
│ └────────┬────────┘ └────────┬────────┘ │
|
||
│ │ │ │
|
||
│ └───────────┬───────────────┘ │
|
||
│ ▼ │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ TRACKING SYSTEM │ │
|
||
│ ├─────────────────────────────────────────────────────┤ │
|
||
│ │ │ │
|
||
│ │ Unique Link ──► Click Track ──► Cookie/Store │ │
|
||
│ │ │ │ │ │ │
|
||
│ │ ?ref=ABC123 IP, Device, 30-90 day │ │
|
||
│ │ Timestamp attribution │ │
|
||
│ │ │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ CONVERSION & COMMISSION │ │
|
||
│ │ │ │
|
||
│ │ Purchase ──► Attribution ──► Commission ──► Payout│ │
|
||
│ │ │ │ │ │ │ │
|
||
│ │ Order ID Match Cookie Calculate Monthly │ │
|
||
│ │ completed to Affiliate 20-50% Stripe │ │
|
||
│ │ │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ Commission Models: │
|
||
│ ├── One-time: Single payment per conversion │
|
||
│ ├── Recurring: % of subscription for lifetime │
|
||
│ ├── Tiered: Higher rates at volume thresholds │
|
||
│ └── Hybrid: One-time + recurring combined │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## Database Schema
|
||
|
||
```typescript
|
||
// prisma/schema.prisma
|
||
model Affiliate {
|
||
id String @id @default(cuid())
|
||
userId String @unique
|
||
user User @relation(fields: [userId], references: [id])
|
||
|
||
// Affiliate identity
|
||
code String @unique // ref=ABC123
|
||
slug String? @unique // custom vanity URL
|
||
|
||
// Commission settings
|
||
commissionType CommissionType @default(PERCENTAGE)
|
||
commissionRate Decimal @default(20) // 20%
|
||
recurringRate Decimal? // For subscription commissions
|
||
|
||
// Tier (affects commission rates)
|
||
tier AffiliateTier @default(STANDARD)
|
||
|
||
// Payout info
|
||
payoutMethod PayoutMethod @default(STRIPE)
|
||
stripeAccountId String?
|
||
paypalEmail String?
|
||
minimumPayout Int @default(5000) // $50.00 minimum
|
||
|
||
// Status
|
||
status AffiliateStatus @default(PENDING)
|
||
approvedAt DateTime?
|
||
suspendedAt DateTime?
|
||
|
||
// Tracking
|
||
clicks Click[]
|
||
conversions Conversion[]
|
||
payouts Payout[]
|
||
|
||
// Stats (denormalized for performance)
|
||
totalClicks Int @default(0)
|
||
totalConversions Int @default(0)
|
||
totalEarnings Int @default(0) // cents
|
||
pendingBalance Int @default(0) // cents
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
model Click {
|
||
id String @id @default(cuid())
|
||
affiliateId String
|
||
affiliate Affiliate @relation(fields: [affiliateId], references: [id])
|
||
|
||
// Tracking data
|
||
ipAddress String?
|
||
userAgent String?
|
||
referer String?
|
||
landingPage String
|
||
|
||
// UTM parameters
|
||
utmSource String?
|
||
utmMedium String?
|
||
utmCampaign String?
|
||
|
||
// Visitor identification
|
||
visitorId String // Fingerprint or cookie ID
|
||
sessionId String?
|
||
|
||
// Conversion tracking
|
||
converted Boolean @default(false)
|
||
conversionId String?
|
||
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([affiliateId, createdAt])
|
||
@@index([visitorId])
|
||
}
|
||
|
||
model Conversion {
|
||
id String @id @default(cuid())
|
||
affiliateId String
|
||
affiliate Affiliate @relation(fields: [affiliateId], references: [id])
|
||
|
||
// Order info
|
||
orderId String @unique
|
||
customerId String
|
||
orderAmount Int // cents
|
||
currency String @default("EUR")
|
||
|
||
// Commission
|
||
commissionAmount Int // cents
|
||
commissionRate Decimal
|
||
commissionType CommissionType
|
||
|
||
// Status
|
||
status ConversionStatus @default(PENDING)
|
||
approvedAt DateTime?
|
||
rejectedAt DateTime?
|
||
rejectionReason String?
|
||
|
||
// Payout tracking
|
||
payoutId String?
|
||
payout Payout? @relation(fields: [payoutId], references: [id])
|
||
paidAt DateTime?
|
||
|
||
// Attribution
|
||
clickId String?
|
||
attributionWindow Int // days
|
||
|
||
// For recurring commissions
|
||
isRecurring Boolean @default(false)
|
||
parentConversionId String?
|
||
recurringMonth Int?
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([affiliateId, status])
|
||
@@index([affiliateId, createdAt])
|
||
}
|
||
|
||
model Payout {
|
||
id String @id @default(cuid())
|
||
affiliateId String
|
||
affiliate Affiliate @relation(fields: [affiliateId], references: [id])
|
||
|
||
amount Int // cents
|
||
currency String @default("EUR")
|
||
fee Int @default(0) // Processing fee
|
||
|
||
method PayoutMethod
|
||
stripeTransferId String?
|
||
paypalPayoutId String?
|
||
|
||
status PayoutStatus @default(PENDING)
|
||
processedAt DateTime?
|
||
failedAt DateTime?
|
||
failureReason String?
|
||
|
||
conversions Conversion[]
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
// Referral-specific (customer referrals)
|
||
model Referral {
|
||
id String @id @default(cuid())
|
||
referrerId String // User who referred
|
||
referrer User @relation("ReferralsMade", fields: [referrerId], references: [id])
|
||
|
||
referredId String? // User who signed up
|
||
referred User? @relation("ReferralsReceived", fields: [referredId], references: [id])
|
||
|
||
code String @unique
|
||
email String? // Invited email (before signup)
|
||
|
||
// Rewards
|
||
referrerReward Int? // cents
|
||
referredReward Int? // cents
|
||
rewardType RewardType @default(CREDIT)
|
||
|
||
// Status
|
||
status ReferralStatus @default(PENDING)
|
||
signedUpAt DateTime?
|
||
qualifiedAt DateTime? // Met qualification criteria
|
||
rewardedAt DateTime?
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
enum CommissionType {
|
||
PERCENTAGE
|
||
FIXED
|
||
TIERED
|
||
}
|
||
|
||
enum AffiliateTier {
|
||
STANDARD // 20%
|
||
SILVER // 25%
|
||
GOLD // 30%
|
||
PLATINUM // 35%
|
||
}
|
||
|
||
enum AffiliateStatus {
|
||
PENDING
|
||
ACTIVE
|
||
SUSPENDED
|
||
TERMINATED
|
||
}
|
||
|
||
enum PayoutMethod {
|
||
STRIPE
|
||
PAYPAL
|
||
BANK_TRANSFER
|
||
}
|
||
|
||
enum ConversionStatus {
|
||
PENDING // Waiting for approval period
|
||
APPROVED // Commission confirmed
|
||
REJECTED // Refund, fraud, etc.
|
||
PAID // Included in payout
|
||
}
|
||
|
||
enum PayoutStatus {
|
||
PENDING
|
||
PROCESSING
|
||
COMPLETED
|
||
FAILED
|
||
}
|
||
|
||
enum RewardType {
|
||
CREDIT // Account credit
|
||
CASH // Cash payout
|
||
DISCOUNT // Discount code
|
||
}
|
||
|
||
enum ReferralStatus {
|
||
PENDING // Invited, not signed up
|
||
SIGNED_UP // Created account
|
||
QUALIFIED // Met criteria (purchase, etc.)
|
||
REWARDED // Rewards distributed
|
||
EXPIRED // Never signed up
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Affiliate Link Generation
|
||
|
||
```typescript
|
||
// lib/affiliate/links.ts
|
||
import { db } from '@/lib/db';
|
||
import crypto from 'crypto';
|
||
|
||
export async function generateAffiliateCode(): Promise<string> {
|
||
const code = crypto.randomBytes(4).toString('hex').toUpperCase();
|
||
|
||
// Ensure uniqueness
|
||
const existing = await db.affiliate.findUnique({
|
||
where: { code }
|
||
});
|
||
|
||
if (existing) {
|
||
return generateAffiliateCode(); // Retry
|
||
}
|
||
|
||
return code;
|
||
}
|
||
|
||
export function buildAffiliateLink(
|
||
baseUrl: string,
|
||
affiliateCode: string,
|
||
campaign?: string
|
||
): string {
|
||
const url = new URL(baseUrl);
|
||
url.searchParams.set('ref', affiliateCode);
|
||
|
||
if (campaign) {
|
||
url.searchParams.set('utm_campaign', campaign);
|
||
url.searchParams.set('utm_source', 'affiliate');
|
||
url.searchParams.set('utm_medium', 'referral');
|
||
}
|
||
|
||
return url.toString();
|
||
}
|
||
|
||
// Generate vanity URL
|
||
export async function setAffiliateSlug(
|
||
affiliateId: string,
|
||
slug: string
|
||
): Promise<boolean> {
|
||
// Validate slug format
|
||
if (!/^[a-z0-9-]{3,30}$/.test(slug)) {
|
||
throw new Error('Invalid slug format');
|
||
}
|
||
|
||
// Check reserved words
|
||
const reserved = ['admin', 'api', 'app', 'www', 'help', 'support'];
|
||
if (reserved.includes(slug)) {
|
||
throw new Error('Slug is reserved');
|
||
}
|
||
|
||
try {
|
||
await db.affiliate.update({
|
||
where: { id: affiliateId },
|
||
data: { slug }
|
||
});
|
||
return true;
|
||
} catch {
|
||
throw new Error('Slug already taken');
|
||
}
|
||
}
|
||
|
||
// Link resolver
|
||
export async function resolveAffiliateLink(
|
||
refCode: string
|
||
): Promise<Affiliate | null> {
|
||
// Try code first
|
||
let affiliate = await db.affiliate.findUnique({
|
||
where: { code: refCode, status: 'ACTIVE' }
|
||
});
|
||
|
||
// Try slug
|
||
if (!affiliate) {
|
||
affiliate = await db.affiliate.findUnique({
|
||
where: { slug: refCode, status: 'ACTIVE' }
|
||
});
|
||
}
|
||
|
||
return affiliate;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Click Tracking
|
||
|
||
```typescript
|
||
// lib/affiliate/tracking.ts
|
||
import { db } from '@/lib/db';
|
||
import { cookies } from 'next/headers';
|
||
import { NextRequest } from 'next/server';
|
||
import crypto from 'crypto';
|
||
|
||
const COOKIE_NAME = 'aff_ref';
|
||
const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 days
|
||
|
||
interface TrackClickParams {
|
||
affiliateId: string;
|
||
request: NextRequest;
|
||
landingPage: string;
|
||
}
|
||
|
||
export async function trackClick(params: TrackClickParams): Promise<Click> {
|
||
const { affiliateId, request, landingPage } = params;
|
||
|
||
const visitorId = getVisitorId(request);
|
||
const searchParams = new URL(request.url).searchParams;
|
||
|
||
const click = await db.click.create({
|
||
data: {
|
||
affiliateId,
|
||
ipAddress: request.headers.get('x-forwarded-for'),
|
||
userAgent: request.headers.get('user-agent'),
|
||
referer: request.headers.get('referer'),
|
||
landingPage,
|
||
utmSource: searchParams.get('utm_source'),
|
||
utmMedium: searchParams.get('utm_medium'),
|
||
utmCampaign: searchParams.get('utm_campaign'),
|
||
visitorId,
|
||
sessionId: crypto.randomUUID()
|
||
}
|
||
});
|
||
|
||
// Update affiliate stats
|
||
await db.affiliate.update({
|
||
where: { id: affiliateId },
|
||
data: { totalClicks: { increment: 1 } }
|
||
});
|
||
|
||
return click;
|
||
}
|
||
|
||
function getVisitorId(request: NextRequest): string {
|
||
// Try to get existing visitor ID from cookie
|
||
const existingId = request.cookies.get('visitor_id')?.value;
|
||
if (existingId) return existingId;
|
||
|
||
// Generate fingerprint from request headers
|
||
const components = [
|
||
request.headers.get('user-agent'),
|
||
request.headers.get('accept-language'),
|
||
request.headers.get('accept-encoding')
|
||
].filter(Boolean).join('|');
|
||
|
||
return crypto.createHash('sha256').update(components).digest('hex').slice(0, 16);
|
||
}
|
||
|
||
// Set affiliate cookie
|
||
export function setAffiliateCookie(affiliateCode: string): void {
|
||
const cookieStore = cookies();
|
||
|
||
cookieStore.set(COOKIE_NAME, affiliateCode, {
|
||
maxAge: COOKIE_MAX_AGE,
|
||
httpOnly: true,
|
||
secure: process.env.NODE_ENV === 'production',
|
||
sameSite: 'lax',
|
||
path: '/'
|
||
});
|
||
}
|
||
|
||
// Get affiliate from cookie
|
||
export function getAffiliateFromCookie(): string | null {
|
||
const cookieStore = cookies();
|
||
return cookieStore.get(COOKIE_NAME)?.value || null;
|
||
}
|
||
|
||
// Attribution window check
|
||
export async function findAttributableClick(
|
||
visitorId: string,
|
||
affiliateId: string,
|
||
windowDays: number = 30
|
||
): Promise<Click | null> {
|
||
const windowStart = new Date();
|
||
windowStart.setDate(windowStart.getDate() - windowDays);
|
||
|
||
return db.click.findFirst({
|
||
where: {
|
||
affiliateId,
|
||
visitorId,
|
||
createdAt: { gte: windowStart },
|
||
converted: false
|
||
},
|
||
orderBy: { createdAt: 'desc' }
|
||
});
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Tracking Middleware
|
||
|
||
```typescript
|
||
// middleware.ts
|
||
import { NextResponse } from 'next/server';
|
||
import type { NextRequest } from 'next/server';
|
||
|
||
export async function middleware(request: NextRequest) {
|
||
const response = NextResponse.next();
|
||
const refCode = request.nextUrl.searchParams.get('ref');
|
||
|
||
if (refCode) {
|
||
// Validate affiliate exists and is active
|
||
const isValid = await validateAffiliateCode(refCode);
|
||
|
||
if (isValid) {
|
||
// Set cookie
|
||
response.cookies.set('aff_ref', refCode, {
|
||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||
httpOnly: true,
|
||
secure: process.env.NODE_ENV === 'production',
|
||
sameSite: 'lax'
|
||
});
|
||
|
||
// Track click asynchronously
|
||
trackClickAsync(refCode, request);
|
||
|
||
// Remove ref from URL for cleaner UX
|
||
const cleanUrl = new URL(request.url);
|
||
cleanUrl.searchParams.delete('ref');
|
||
|
||
if (cleanUrl.toString() !== request.url) {
|
||
return NextResponse.redirect(cleanUrl);
|
||
}
|
||
}
|
||
}
|
||
|
||
return response;
|
||
}
|
||
|
||
async function validateAffiliateCode(code: string): Promise<boolean> {
|
||
// Fast validation via edge-compatible check
|
||
const response = await fetch(
|
||
`${process.env.NEXT_PUBLIC_APP_URL}/api/affiliate/validate?code=${code}`
|
||
);
|
||
return response.ok;
|
||
}
|
||
|
||
function trackClickAsync(refCode: string, request: NextRequest): void {
|
||
// Fire and forget
|
||
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/affiliate/track`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
refCode,
|
||
landingPage: request.nextUrl.pathname,
|
||
userAgent: request.headers.get('user-agent'),
|
||
referer: request.headers.get('referer'),
|
||
ip: request.headers.get('x-forwarded-for')
|
||
})
|
||
}).catch(console.error);
|
||
}
|
||
|
||
export const config = {
|
||
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)']
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## Commission Calculation
|
||
|
||
```typescript
|
||
// lib/affiliate/commission.ts
|
||
import { db } from '@/lib/db';
|
||
import { Decimal } from '@prisma/client/runtime/library';
|
||
|
||
interface CommissionResult {
|
||
amount: number;
|
||
rate: Decimal;
|
||
type: CommissionType;
|
||
}
|
||
|
||
// Tier-based commission rates
|
||
const TIER_RATES: Record<AffiliateTier, number> = {
|
||
STANDARD: 20,
|
||
SILVER: 25,
|
||
GOLD: 30,
|
||
PLATINUM: 35
|
||
};
|
||
|
||
export function calculateCommission(
|
||
orderAmount: number,
|
||
affiliate: Affiliate
|
||
): CommissionResult {
|
||
let rate: Decimal;
|
||
let amount: number;
|
||
|
||
switch (affiliate.commissionType) {
|
||
case 'PERCENTAGE':
|
||
rate = affiliate.tier
|
||
? new Decimal(TIER_RATES[affiliate.tier])
|
||
: affiliate.commissionRate;
|
||
amount = Math.floor(orderAmount * rate.toNumber() / 100);
|
||
break;
|
||
|
||
case 'FIXED':
|
||
rate = affiliate.commissionRate;
|
||
amount = rate.toNumber() * 100; // Convert to cents
|
||
break;
|
||
|
||
case 'TIERED':
|
||
const tierResult = calculateTieredCommission(orderAmount, affiliate);
|
||
rate = tierResult.rate;
|
||
amount = tierResult.amount;
|
||
break;
|
||
|
||
default:
|
||
rate = new Decimal(20);
|
||
amount = Math.floor(orderAmount * 0.2);
|
||
}
|
||
|
||
return {
|
||
amount,
|
||
rate,
|
||
type: affiliate.commissionType
|
||
};
|
||
}
|
||
|
||
function calculateTieredCommission(
|
||
orderAmount: number,
|
||
affiliate: Affiliate
|
||
): { amount: number; rate: Decimal } {
|
||
// Get affiliate's total conversions this month
|
||
const monthStart = new Date();
|
||
monthStart.setDate(1);
|
||
monthStart.setHours(0, 0, 0, 0);
|
||
|
||
// Tiered rates based on monthly volume
|
||
const tiers = [
|
||
{ threshold: 10000_00, rate: 20 }, // $10k+ = 20%
|
||
{ threshold: 5000_00, rate: 25 }, // $5k+ = 25%
|
||
{ threshold: 1000_00, rate: 30 }, // $1k+ = 30%
|
||
{ threshold: 0, rate: 35 } // Base = 35%
|
||
];
|
||
|
||
// For simplicity, use the affiliate's total earnings to determine tier
|
||
const volumeTier = tiers.find(t => affiliate.totalEarnings >= t.threshold);
|
||
const rate = volumeTier?.rate || 20;
|
||
|
||
return {
|
||
amount: Math.floor(orderAmount * rate / 100),
|
||
rate: new Decimal(rate)
|
||
};
|
||
}
|
||
|
||
// Calculate recurring commission for subscriptions
|
||
export function calculateRecurringCommission(
|
||
subscriptionAmount: number,
|
||
affiliate: Affiliate,
|
||
month: number
|
||
): CommissionResult | null {
|
||
// Most programs cap recurring at 12 months
|
||
if (month > 12) return null;
|
||
|
||
// Recurring rate might be lower than initial
|
||
const recurringRate = affiliate.recurringRate || affiliate.commissionRate;
|
||
const amount = Math.floor(subscriptionAmount * recurringRate.toNumber() / 100);
|
||
|
||
return {
|
||
amount,
|
||
rate: recurringRate,
|
||
type: 'PERCENTAGE'
|
||
};
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Conversion Recording
|
||
|
||
```typescript
|
||
// lib/affiliate/conversions.ts
|
||
import { db } from '@/lib/db';
|
||
import { calculateCommission } from './commission';
|
||
import { getAffiliateFromCookie } from './tracking';
|
||
|
||
interface RecordConversionParams {
|
||
orderId: string;
|
||
customerId: string;
|
||
orderAmount: number;
|
||
currency?: string;
|
||
isSubscription?: boolean;
|
||
}
|
||
|
||
export async function recordConversion(
|
||
params: RecordConversionParams
|
||
): Promise<Conversion | null> {
|
||
const { orderId, customerId, orderAmount, currency = 'EUR', isSubscription } = params;
|
||
|
||
// Check for existing conversion (prevent duplicates)
|
||
const existing = await db.conversion.findUnique({
|
||
where: { orderId }
|
||
});
|
||
|
||
if (existing) return existing;
|
||
|
||
// Get affiliate from cookie or customer's first touch
|
||
const affiliateCode = getAffiliateFromCookie();
|
||
if (!affiliateCode) return null;
|
||
|
||
const affiliate = await db.affiliate.findUnique({
|
||
where: { code: affiliateCode, status: 'ACTIVE' }
|
||
});
|
||
|
||
if (!affiliate) return null;
|
||
|
||
// Calculate commission
|
||
const commission = calculateCommission(orderAmount, affiliate);
|
||
|
||
// Find attributable click
|
||
const click = await db.click.findFirst({
|
||
where: {
|
||
affiliateId: affiliate.id,
|
||
converted: false,
|
||
createdAt: {
|
||
gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) // 30 days
|
||
}
|
||
},
|
||
orderBy: { createdAt: 'desc' }
|
||
});
|
||
|
||
// Create conversion
|
||
const conversion = await db.$transaction(async (tx) => {
|
||
// Create conversion record
|
||
const conv = await tx.conversion.create({
|
||
data: {
|
||
affiliateId: affiliate.id,
|
||
orderId,
|
||
customerId,
|
||
orderAmount,
|
||
currency,
|
||
commissionAmount: commission.amount,
|
||
commissionRate: commission.rate,
|
||
commissionType: commission.type,
|
||
clickId: click?.id,
|
||
attributionWindow: 30,
|
||
isRecurring: isSubscription
|
||
}
|
||
});
|
||
|
||
// Mark click as converted
|
||
if (click) {
|
||
await tx.click.update({
|
||
where: { id: click.id },
|
||
data: { converted: true, conversionId: conv.id }
|
||
});
|
||
}
|
||
|
||
// Update affiliate stats
|
||
await tx.affiliate.update({
|
||
where: { id: affiliate.id },
|
||
data: {
|
||
totalConversions: { increment: 1 },
|
||
pendingBalance: { increment: commission.amount }
|
||
}
|
||
});
|
||
|
||
return conv;
|
||
});
|
||
|
||
return conversion;
|
||
}
|
||
|
||
// Auto-approve conversions after hold period
|
||
export async function approveConversions(): Promise<number> {
|
||
const holdPeriod = 14; // days
|
||
const cutoffDate = new Date();
|
||
cutoffDate.setDate(cutoffDate.getDate() - holdPeriod);
|
||
|
||
const pendingConversions = await db.conversion.findMany({
|
||
where: {
|
||
status: 'PENDING',
|
||
createdAt: { lte: cutoffDate }
|
||
}
|
||
});
|
||
|
||
let approved = 0;
|
||
|
||
for (const conversion of pendingConversions) {
|
||
// Check if order was refunded
|
||
const wasRefunded = await checkOrderRefunded(conversion.orderId);
|
||
|
||
if (wasRefunded) {
|
||
await db.conversion.update({
|
||
where: { id: conversion.id },
|
||
data: {
|
||
status: 'REJECTED',
|
||
rejectedAt: new Date(),
|
||
rejectionReason: 'Order refunded'
|
||
}
|
||
});
|
||
|
||
// Reduce pending balance
|
||
await db.affiliate.update({
|
||
where: { id: conversion.affiliateId },
|
||
data: { pendingBalance: { decrement: conversion.commissionAmount } }
|
||
});
|
||
} else {
|
||
await db.conversion.update({
|
||
where: { id: conversion.id },
|
||
data: {
|
||
status: 'APPROVED',
|
||
approvedAt: new Date()
|
||
}
|
||
});
|
||
|
||
// Move from pending to earned
|
||
await db.affiliate.update({
|
||
where: { id: conversion.affiliateId },
|
||
data: {
|
||
pendingBalance: { decrement: conversion.commissionAmount },
|
||
totalEarnings: { increment: conversion.commissionAmount }
|
||
}
|
||
});
|
||
|
||
approved++;
|
||
}
|
||
}
|
||
|
||
return approved;
|
||
}
|
||
|
||
async function checkOrderRefunded(orderId: string): Promise<boolean> {
|
||
// Check with payment provider
|
||
const order = await db.order.findUnique({
|
||
where: { id: orderId }
|
||
});
|
||
return order?.status === 'REFUNDED';
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Payout System
|
||
|
||
```typescript
|
||
// lib/affiliate/payouts.ts
|
||
import { db } from '@/lib/db';
|
||
import Stripe from 'stripe';
|
||
|
||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
||
|
||
interface ProcessPayoutsResult {
|
||
processed: number;
|
||
failed: number;
|
||
totalAmount: number;
|
||
}
|
||
|
||
export async function processPayouts(): Promise<ProcessPayoutsResult> {
|
||
const result = { processed: 0, failed: 0, totalAmount: 0 };
|
||
|
||
// Find affiliates with approved conversions ready for payout
|
||
const affiliatesWithBalance = await db.affiliate.findMany({
|
||
where: {
|
||
status: 'ACTIVE',
|
||
totalEarnings: { gte: db.affiliate.fields.minimumPayout }
|
||
},
|
||
include: {
|
||
conversions: {
|
||
where: {
|
||
status: 'APPROVED',
|
||
payoutId: null
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
for (const affiliate of affiliatesWithBalance) {
|
||
if (affiliate.conversions.length === 0) continue;
|
||
|
||
const payoutAmount = affiliate.conversions.reduce(
|
||
(sum, c) => sum + c.commissionAmount,
|
||
0
|
||
);
|
||
|
||
if (payoutAmount < affiliate.minimumPayout) continue;
|
||
|
||
try {
|
||
const payout = await createPayout(affiliate, payoutAmount);
|
||
|
||
// Link conversions to payout
|
||
await db.conversion.updateMany({
|
||
where: {
|
||
id: { in: affiliate.conversions.map(c => c.id) }
|
||
},
|
||
data: {
|
||
payoutId: payout.id,
|
||
status: 'PAID',
|
||
paidAt: new Date()
|
||
}
|
||
});
|
||
|
||
// Reset affiliate balance
|
||
await db.affiliate.update({
|
||
where: { id: affiliate.id },
|
||
data: { totalEarnings: { decrement: payoutAmount } }
|
||
});
|
||
|
||
result.processed++;
|
||
result.totalAmount += payoutAmount;
|
||
|
||
} catch (error) {
|
||
console.error(`Payout failed for ${affiliate.id}:`, error);
|
||
result.failed++;
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
async function createPayout(
|
||
affiliate: Affiliate,
|
||
amount: number
|
||
): Promise<Payout> {
|
||
// Create payout record
|
||
const payout = await db.payout.create({
|
||
data: {
|
||
affiliateId: affiliate.id,
|
||
amount,
|
||
currency: 'EUR',
|
||
method: affiliate.payoutMethod,
|
||
status: 'PROCESSING'
|
||
}
|
||
});
|
||
|
||
try {
|
||
switch (affiliate.payoutMethod) {
|
||
case 'STRIPE':
|
||
await processStripePayout(affiliate, payout);
|
||
break;
|
||
case 'PAYPAL':
|
||
await processPayPalPayout(affiliate, payout);
|
||
break;
|
||
default:
|
||
throw new Error('Unsupported payout method');
|
||
}
|
||
|
||
await db.payout.update({
|
||
where: { id: payout.id },
|
||
data: {
|
||
status: 'COMPLETED',
|
||
processedAt: new Date()
|
||
}
|
||
});
|
||
|
||
} catch (error) {
|
||
await db.payout.update({
|
||
where: { id: payout.id },
|
||
data: {
|
||
status: 'FAILED',
|
||
failedAt: new Date(),
|
||
failureReason: error instanceof Error ? error.message : 'Unknown error'
|
||
}
|
||
});
|
||
throw error;
|
||
}
|
||
|
||
return payout;
|
||
}
|
||
|
||
async function processStripePayout(
|
||
affiliate: Affiliate,
|
||
payout: Payout
|
||
): Promise<void> {
|
||
if (!affiliate.stripeAccountId) {
|
||
throw new Error('No Stripe account connected');
|
||
}
|
||
|
||
const transfer = await stripe.transfers.create({
|
||
amount: payout.amount,
|
||
currency: payout.currency.toLowerCase(),
|
||
destination: affiliate.stripeAccountId,
|
||
metadata: {
|
||
payoutId: payout.id,
|
||
affiliateId: affiliate.id
|
||
}
|
||
});
|
||
|
||
await db.payout.update({
|
||
where: { id: payout.id },
|
||
data: { stripeTransferId: transfer.id }
|
||
});
|
||
}
|
||
|
||
async function processPayPalPayout(
|
||
affiliate: Affiliate,
|
||
payout: Payout
|
||
): Promise<void> {
|
||
if (!affiliate.paypalEmail) {
|
||
throw new Error('No PayPal email configured');
|
||
}
|
||
|
||
// PayPal Payouts API integration
|
||
const paypalPayout = await createPayPalPayout({
|
||
email: affiliate.paypalEmail,
|
||
amount: (payout.amount / 100).toFixed(2),
|
||
currency: payout.currency,
|
||
note: `Affiliate commission payout #${payout.id}`
|
||
});
|
||
|
||
await db.payout.update({
|
||
where: { id: payout.id },
|
||
data: { paypalPayoutId: paypalPayout.batch_id }
|
||
});
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Customer Referral System
|
||
|
||
```typescript
|
||
// lib/referral/referrals.ts
|
||
import { db } from '@/lib/db';
|
||
import crypto from 'crypto';
|
||
|
||
const REFERRAL_REWARDS = {
|
||
referrer: 1000, // $10 credit
|
||
referred: 500 // $5 credit
|
||
};
|
||
|
||
export async function createReferralCode(userId: string): Promise<string> {
|
||
const existing = await db.referral.findFirst({
|
||
where: { referrerId: userId }
|
||
});
|
||
|
||
if (existing) return existing.code;
|
||
|
||
const code = crypto.randomBytes(4).toString('hex').toUpperCase();
|
||
|
||
await db.referral.create({
|
||
data: {
|
||
referrerId: userId,
|
||
code,
|
||
status: 'PENDING'
|
||
}
|
||
});
|
||
|
||
return code;
|
||
}
|
||
|
||
export async function processReferralSignup(
|
||
newUserId: string,
|
||
referralCode: string
|
||
): Promise<boolean> {
|
||
const referral = await db.referral.findUnique({
|
||
where: { code: referralCode }
|
||
});
|
||
|
||
if (!referral || referral.status !== 'PENDING') {
|
||
return false;
|
||
}
|
||
|
||
// Prevent self-referral
|
||
if (referral.referrerId === newUserId) {
|
||
return false;
|
||
}
|
||
|
||
await db.referral.update({
|
||
where: { id: referral.id },
|
||
data: {
|
||
referredId: newUserId,
|
||
status: 'SIGNED_UP',
|
||
signedUpAt: new Date()
|
||
}
|
||
});
|
||
|
||
return true;
|
||
}
|
||
|
||
export async function qualifyReferral(
|
||
referredUserId: string
|
||
): Promise<void> {
|
||
const referral = await db.referral.findFirst({
|
||
where: {
|
||
referredId: referredUserId,
|
||
status: 'SIGNED_UP'
|
||
}
|
||
});
|
||
|
||
if (!referral) return;
|
||
|
||
await db.$transaction([
|
||
// Update referral status
|
||
db.referral.update({
|
||
where: { id: referral.id },
|
||
data: {
|
||
status: 'QUALIFIED',
|
||
qualifiedAt: new Date(),
|
||
referrerReward: REFERRAL_REWARDS.referrer,
|
||
referredReward: REFERRAL_REWARDS.referred
|
||
}
|
||
}),
|
||
|
||
// Credit referrer
|
||
db.user.update({
|
||
where: { id: referral.referrerId },
|
||
data: { credit: { increment: REFERRAL_REWARDS.referrer } }
|
||
}),
|
||
|
||
// Credit referred user
|
||
db.user.update({
|
||
where: { id: referredUserId },
|
||
data: { credit: { increment: REFERRAL_REWARDS.referred } }
|
||
})
|
||
]);
|
||
|
||
// Send notifications
|
||
await notifyReferralReward(referral.referrerId, REFERRAL_REWARDS.referrer);
|
||
await notifyReferralReward(referredUserId, REFERRAL_REWARDS.referred);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Affiliate Dashboard API
|
||
|
||
```typescript
|
||
// app/api/affiliate/dashboard/route.ts
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { db } from '@/lib/db';
|
||
import { auth } from '@/lib/auth';
|
||
|
||
export async function GET(request: NextRequest) {
|
||
const session = await auth();
|
||
if (!session?.user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||
}
|
||
|
||
const affiliate = await db.affiliate.findUnique({
|
||
where: { userId: session.user.id }
|
||
});
|
||
|
||
if (!affiliate) {
|
||
return NextResponse.json({ error: 'Not an affiliate' }, { status: 404 });
|
||
}
|
||
|
||
// Get date range
|
||
const { searchParams } = new URL(request.url);
|
||
const range = searchParams.get('range') || '30d';
|
||
const startDate = getStartDate(range);
|
||
|
||
// Fetch stats
|
||
const [clicks, conversions, recentConversions] = await Promise.all([
|
||
db.click.count({
|
||
where: {
|
||
affiliateId: affiliate.id,
|
||
createdAt: { gte: startDate }
|
||
}
|
||
}),
|
||
|
||
db.conversion.aggregate({
|
||
where: {
|
||
affiliateId: affiliate.id,
|
||
createdAt: { gte: startDate }
|
||
},
|
||
_count: true,
|
||
_sum: { commissionAmount: true, orderAmount: true }
|
||
}),
|
||
|
||
db.conversion.findMany({
|
||
where: { affiliateId: affiliate.id },
|
||
orderBy: { createdAt: 'desc' },
|
||
take: 10,
|
||
select: {
|
||
id: true,
|
||
orderAmount: true,
|
||
commissionAmount: true,
|
||
status: true,
|
||
createdAt: true
|
||
}
|
||
})
|
||
]);
|
||
|
||
// Calculate conversion rate
|
||
const conversionRate = clicks > 0
|
||
? ((conversions._count || 0) / clicks * 100).toFixed(2)
|
||
: '0.00';
|
||
|
||
return NextResponse.json({
|
||
affiliate: {
|
||
code: affiliate.code,
|
||
tier: affiliate.tier,
|
||
commissionRate: affiliate.commissionRate,
|
||
status: affiliate.status
|
||
},
|
||
stats: {
|
||
clicks,
|
||
conversions: conversions._count || 0,
|
||
conversionRate: `${conversionRate}%`,
|
||
revenue: conversions._sum.orderAmount || 0,
|
||
earnings: conversions._sum.commissionAmount || 0,
|
||
pendingBalance: affiliate.pendingBalance,
|
||
totalEarnings: affiliate.totalEarnings
|
||
},
|
||
recentConversions,
|
||
links: {
|
||
main: `${process.env.NEXT_PUBLIC_APP_URL}?ref=${affiliate.code}`,
|
||
pricing: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?ref=${affiliate.code}`
|
||
}
|
||
});
|
||
}
|
||
|
||
function getStartDate(range: string): Date {
|
||
const now = new Date();
|
||
switch (range) {
|
||
case '7d': return new Date(now.setDate(now.getDate() - 7));
|
||
case '30d': return new Date(now.setDate(now.getDate() - 30));
|
||
case '90d': return new Date(now.setDate(now.getDate() - 90));
|
||
case 'ytd': return new Date(now.getFullYear(), 0, 1);
|
||
default: return new Date(now.setDate(now.getDate() - 30));
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Best Practices
|
||
|
||
| Aspect | Recommendation |
|
||
|--------|----------------|
|
||
| **Attribution** | 30-90 day cookie window |
|
||
| **Hold Period** | 14-30 days before approval |
|
||
| **Commission** | 20-50% for digital products |
|
||
| **Minimum Payout** | $50-100 threshold |
|
||
| **Fraud Prevention** | IP tracking, refund checks |
|
||
| **Communication** | Real-time dashboard, email alerts |
|
||
|
||
---
|
||
|
||
## Fazit
|
||
|
||
Affiliate & Referral Systems erfordern:
|
||
|
||
1. **Tracking**: Cookies, Attribution-Windows
|
||
2. **Commission**: Faire, klare Berechnung
|
||
3. **Payouts**: Automatisiert, zuverlässig
|
||
4. **Dashboard**: Transparenz für Partner
|
||
|
||
Gute Partner-Programme wachsen organisch durch zufriedene Affiliates.
|
||
|
||
---
|
||
|
||
## Bildprompts
|
||
|
||
1. "Affiliate dashboard interface, earnings chart and conversion metrics"
|
||
2. "Referral flow diagram, invite friend reward visualization"
|
||
3. "Commission payout system, money transfer illustration"
|
||
|
||
---
|
||
|
||
## Quellen
|
||
|
||
- [Stripe Connect for Marketplaces](https://stripe.com/docs/connect)
|
||
- [First Promoter](https://firstpromoter.com/)
|
||
- [Rewardful API](https://www.rewardful.com/)
|
||
- [Affiliate Marketing Best Practices](https://www.refersion.com/blog/affiliate-marketing-best-practices/)
|