@happyvertical/smrt-affiliates

Partner revenue sharing with multi-type partners, multi-tier commissions, and payout processing โ€” a cross-tenant network by design.

v0.29.34Revenue ShareCommissionsPayoutsCross-Tenant

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

bash
npm install @happyvertical/smrt-affiliates

Quick Start

typescript
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

typescript
// @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)

typescript
// @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

typescript
// @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:

typescript
// 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

typescript
// 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 later

Best 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)

Related Modules