@happyvertical/smrt-affiliates
Partner revenue sharing with multi-type partners, multi-tier commissions, and payout processing โ a cross-tenant network by design.
Overview
smrt-affiliates tracks affiliate partners and commissions for revenue-sharing networks. Partners can hold multiple roles, commissions are immutable records tied to ad events, and payouts aggregate earnings into batch disbursements. The whole network is cross-tenant by design โ see Tenancy below.
Installation
npm install @happyvertical/smrt-affiliatesQuick Start
import {
Partner, PartnerCollection,
Commission, CommissionCollection,
Payout, PayoutCollection,
PartnerType, CommissionType, CommissionStatus, PayoutStatus
} from '@happyvertical/smrt-affiliates';
// Register a publisher partner (earns display commissions)
const partners = new PartnerCollection(db);
const publisher = await partners.create({
profileId: 'profile-uuid',
propertyId: 'property-uuid',
partnerTypes: JSON.stringify([PartnerType.PUBLISHER]),
displayCommissionRate: 0.50,
status: 'active',
});
// Attach a salesperson to the publisher
// parentCommissionShare: 20% of sales commission goes to parent
const salesperson = await partners.create({
profileId: 'sales-profile-uuid',
parentPartnerId: publisher.id,
partnerTypes: JSON.stringify([PartnerType.SALESPERSON]),
salesCommissionRate: 0.10,
parentCommissionShare: 0.20,
status: 'active',
});
// Effective sales rate: 0.10 * (1 - 0.20) = 0.08
salesperson.getEffectiveSalesRate(); // 0.08
// Record a commission (all monetary values in integer cents)
const commissions = new CommissionCollection(db);
await commissions.create({
eventId: 'adevent-uuid',
partnerId: publisher.id,
commissionType: CommissionType.DISPLAY,
grossRevenue: 1000, // $10.00
commissionRate: 0.50,
commissionAmount: Commission.calculateAmount(1000, 0.50), // 500 cents
currency: 'CAD',
status: CommissionStatus.PENDING,
});
// Create a payout batch
const payouts = new PayoutCollection(db);
const payout = await payouts.create({
partnerId: publisher.id,
periodStart: new Date('2024-01-01'),
periodEnd: new Date('2024-01-31'),
displayEarnings: 25000, // $250.00
referralEarnings: 500, // $5.00
salesEarnings: 0,
parentEarnings: 0,
totalAmount: 25500, // $255.00
currency: 'CAD',
status: PayoutStatus.PENDING,
});
// Payout lifecycle
payout.approve();
payout.markProcessing();
payout.complete('transfer-ref-123');
await payout.save();Core Models
Partner
// @smrt(...) โ deliberately NOT @TenantScoped (cross-tenant network; see Tenancy)
class Partner extends SmrtObject {
profileId: string // plain string โ smrt-profiles
propertyId?: string // plain string โ smrt-properties
parentPartnerId?: string // Parent publisher hierarchy
referredById?: string // Referral attribution
partnerTypes: string // JSON array of PartnerType (multi-role)
displayCommissionRate: number
salesCommissionRate: number
referralCommissionRate: number
parentCommissionShare: number // Share passed to parent publisher
status: 'pending' | 'active' | 'suspended'
getPartnerTypes(): PartnerType[] // parses JSON string
getEffectiveSalesRate(): number // salesCommissionRate ร (1 โ parentCommissionShare)
}Commission (Immutable)
// @smrt({ api: { include: ['create', 'list', 'get'] }, ... }) โ no update/delete (audit trail); NOT tenant-scoped
class Commission extends SmrtObject {
eventId: string // plain string โ smrt-ads AdEvent
partnerId: string
commissionType: 'display' | 'referral' | 'sales' | 'parent' | 'overhead'
grossRevenue: number // Integer cents
commissionRate: number // copied at event time โ NOT a live ref to Partner.commissionRate
commissionAmount: number // Integer cents (uses Math.round())
currency: string
status: 'pending' | 'included' | 'paid'
static calculateAmount(grossRevenue: number, rate: number): number
getAmountInDollars(): number
}Payout
// @smrt(...) โ NOT tenant-scoped (cross-tenant network)
class Payout extends SmrtObject {
partnerId: string
periodStart: Date
periodEnd: Date
displayEarnings: number // Integer cents
referralEarnings: number
salesEarnings: number
parentEarnings: number
totalAmount: number // Integer cents
currency: string
invoiceId?: string // plain string โ smrt-commerce (external mapping)
status: 'pending' | 'approved' | 'processing' | 'completed' | 'failed'
approve(): void
markProcessing(): void
complete(reference: string): void
getTotalInDollars(): number
}Tenancy
None of the three models in this package are tenant-scoped. This is
deliberate. Per docs/content/standards.md ยง7, tenant-aware models normally apply @TenantScoped({ mode: 'optional' }) from smrt-tenancy; each @smrt(...) block in this package
carries an inline comment pointing back to this rationale.
The affiliate network is a cross-tenant graph by design:
- Partner โ a single publisher operating sites across different tenants needs a stable identity for revenue aggregation, payout thresholds, and tax reporting. Slicing per tenant would either duplicate the row or hide payouts owed across tenants.
- Commission โ attributes revenue to a partner across whichever tenant generated the underlying ad event. Cross-tenant attribution is the entire point.
- Payout โ aggregates commissions for a partner regardless of which tenant the revenue came from. A tenant-scoped query would produce systematically incorrect totals.
This is the same reasoning that keeps TenantKey in smrt-secrets out of the tenancy interceptor: rows that must
be queried across tenants to fulfil their purpose should not be silently filtered.
Need tenant-attributed reporting? Aggregate by joining Commission rows back to eventId (in smrt-ads) and the
originating ad's tenant โ don't add @TenantScoped here.
Currency: Integer Cents
All monetary fields in this package are integer cents (unlike smrt-commerce, which uses decimal fields). Display values must be converted via the provided helpers:
// Storage (integer cents)
const commission = await commissions.create({
grossRevenue: 1000, // $10.00
commissionRate: 0.50,
commissionAmount: Commission.calculateAmount(1000, 0.50), // 500 cents โ uses Math.round()
});
// Display (dollars)
commission.getAmountInDollars(); // 5.00
payout.getTotalInDollars(); // total payout in dollars
// Effective sales rate accounting for parent share
salesperson.parentCommissionShare = 0.20; // 20% to parent publisher
salesperson.getEffectiveSalesRate();
// = salesCommissionRate ร (1 โ 0.20)Commission Types
// Each ad event can generate up to 4 commissions:
//
// DISPLAY -> Publisher (site owner earns share of impression revenue)
// REFERRAL -> Referrer (partner who referred the publisher)
// SALES -> Salesperson (partner who brought in the advertiser)
// PARENT -> Parent publisher (share of salesperson's commission)
//
// Parent commission share example:
// Salesperson.parentCommissionShare = 0.20 (20%)
// Effective sales rate = salesRate * (1 - parentCommissionShare)
// Commission rate is copied at event time (immutable record)
// It does NOT update if Partner.commissionRate changes laterBest Practices
DOs
- Use
getPartnerTypes()to parse the JSON string array - Store all monetary values as integer cents (divide by 100 for display)
- Use
Commission.calculateAmount()for consistent rounding - Copy commission rates at event time for immutable attribution
- Use payout lifecycle methods for status transitions
DON'Ts
- Don't modify or delete Commission records (immutable by design)
- Don't reference Partner.commissionRate after event time (use the copied rate)
- Don't display cent values directly (use
getTotalInDollars()helpers) - Don't add tenant scoping (intentionally cross-tenant for network visibility)
- Don't assume payout maps to ledger entries (integration is external)