DEV Community

Anand Rathnas
Anand Rathnas

Posted on • Originally published at jo4.io

Building an Affiliate Marketplace from Scratch: 14 Tables, 27 Services, Zero Money Movement

This article was originally published on Jo4 Blog.

Everyone told me the same thing: "If you're building an affiliate marketplace, you need a payments team."

No, you don't. You need a very clear opinion about what your platform should and shouldn't do. For jo4, that opinion is: we never hold, move, or touch money. 14 database tables, 27 services, 146 source files, 81 test files — and not a single money-movement API call.

Here's how.

TL;DR

jo4's affiliate marketplace connects brands (advertisers) with publishers (affiliates). Brands create campaigns, publishers apply with bids, and when a conversion happens, we record it and calculate commission. That's it. We never process payments. Settlement is a ledger entry that says "brand owes publisher X" — the actual payment happens externally.

The Money Flow (That Doesn't Flow Through Us)

This is the part that confuses people, so let me draw it out:

  1. Brand connects their Stripe account via Stripe Connect OAuth
  2. Customer buys something on the brand's site → Stripe checkout fires a webhook
  3. jo4 receives the webhook → verifies it came from the brand's connected account
  4. jo4 records the conversion and calculates commission (CPA or RevShare)
  5. Monthly settlement scheduler sums what each brand owes each publisher
  6. Brand pays publisher externally (wire, PayPal, whatever they agreed on)
  7. Brand marks the settlement as paid in jo4

Steps 1-4 are automated. Steps 5-7 are the "boring" part that keeps us out of money transmission regulations.

Stripe Connect OAuth is used ONLY for webhook verification. We're confirming that checkout events actually came from the brand's connected Stripe account. We never initiate charges, transfers, or payouts through it.

The Commission Engine

Two models, both dead simple:

public enum CommissionType {
    CPA,        // Fixed amount per conversion ($10 per sale)
    REV_SHARE   // Percentage of sale (15% of order total)
}
Enter fullscreen mode Exit fullscreen mode

The calculation happens in CommissionCalculatorService:

public BigDecimal calculateCommission(CampaignEntity campaign, BigDecimal saleAmount) {
    return switch (campaign.getCommissionType()) {
        case CPA -> campaign.getCommissionAmount();
        case REV_SHARE -> saleAmount
            .multiply(campaign.getCommissionRate())
            .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
    };
}
Enter fullscreen mode Exit fullscreen mode

No tiered rates, no graduated scales, no time-based multipliers. Those are features you add when you have paying customers asking for them, not when you're building V1.

Fraud Scoring

Every conversion gets a fraud score before it's counted:

  • Velocity check: How many conversions from this publisher in the last hour? Spike beyond 3x the rolling average = flag
  • Click-to-conversion time: If someone clicks an affiliate link and converts in under 2 seconds, that's suspicious. Humans don't buy that fast
  • IP clustering: Multiple conversions from the same IP range within a window

Flagged conversions aren't rejected — they're held for review. The brand decides. This is important: we don't make fraud decisions, we surface signals. The brand owns the risk.

Settlement: Monthly with Advisory Locks

The settlement scheduler runs on the 1st of every month at 2 AM UTC:

@Scheduled(cron = "0 0 2 1 * *")
public void runMonthlySettlement() {
    if (!tryAcquireAdvisoryLock(SETTLEMENT_LOCK_ID)) {
        log.info("Another instance is running settlement, skipping");
        return;
    }
    try {
        processSettlements();
    } finally {
        releaseAdvisoryLock(SETTLEMENT_LOCK_ID);
    }
}
Enter fullscreen mode Exit fullscreen mode

PostgreSQL advisory locks handle distributed coordination. If you're running multiple instances (we are), only one will process settlements. The others gracefully skip.

The processSettlements() method groups all approved conversions by brand-publisher pair, sums commissions, and creates a settlement record:

@Data
public class SettlementEntity {
    private Long brandProfileId;
    private Long publisherProfileId;
    private BigDecimal totalAmount;
    private SettlementStatus status;  // PENDING, PAID, DISPUTED
    private YearMonth settlementPeriod;
}
Enter fullscreen mode Exit fullscreen mode

Status starts as PENDING. The brand reviews it, pays the publisher however they agreed to, then marks it PAID in jo4. If there's a disagreement, it goes to DISPUTED and they work it out offline.

No Clawback Logic

This was a deliberate architectural decision. Many affiliate platforms implement clawback windows — if a customer refunds within 30 days, the commission is reversed.

We don't do this.

The brand sets the campaign terms. If they want a clawback window, they handle it in their settlement review before marking it paid. The risk is entirely on the campaign owner, which matches the industry standard for self-serve affiliate platforms.

Adding clawback logic would mean: tracking refund webhooks, implementing reversal entries, handling partial refunds, dealing with already-paid settlements, and building a dispute resolution flow. That's 4-6 weeks of work for a feature that solves a problem the brand can solve by waiting 30 days before paying.

The Database: 14 Tables

Here's the schema at a glance:

  • users / profiles — auth and account data
  • campaigns — brand's offer (commission type, rate, terms, FTC disclosure)
  • bids — publisher's application to a campaign
  • bid_templates — reusable pitch + commission presets
  • clicks — every affiliate link click, timestamped and IP-logged
  • conversions — confirmed sales with fraud scores
  • settlements — monthly brand-to-publisher ledger
  • stripe_connections — OAuth tokens for webhook verification
  • notifications — in-app alerts for bid status changes, new conversions
  • webhooks / webhook_events — outbound event delivery to brand's systems

14 tables. Each one maps to exactly one domain concept. No multi-purpose "events" table. No EAV pattern. No JSON columns for "flexibility."

27 Services, 146 Source Files

The service layer follows a strict pattern: one service per domain operation, not one service per entity.

  • CampaignActivationService — handles FTC gates, status transitions
  • BidStateMachineService — manages bid lifecycle (covered in the next post)
  • ConversionRecordingService — webhook → fraud check → commission calculation
  • SettlementSchedulerService — monthly aggregation + advisory locks
  • StripeConnectService — OAuth flow + webhook signature verification

Each service gets its own test file. 81 test files total. That's a test-to-source ratio of 0.55, which is about right for a platform where bugs mean incorrect money calculations.

What I'd Do Differently

Start with fewer tables. I could have shipped the MVP with 8 tables. Bid templates, fraud scoring, and the full notification system could have waited.

Skip Stripe Connect for V1. We could have started with manual webhook URL configuration instead of OAuth. The OAuth flow is slick but it delayed the launch by a week.

Use event sourcing for conversions. Right now, conversion status is a mutable column. If I were starting over, I'd make every status change an immutable event. Debugging "why was this conversion rejected?" would be trivial.

The Numbers

  • 14 database tables
  • 27 services
  • 146 source files
  • 81 test files
  • 0 money-movement API calls
  • 0 money transmission license requirements

That last line is the whole point. By refusing to move money, we removed an entire category of regulatory, security, and operational complexity. The tradeoff is that settlement is manual — but that's a feature, not a bug, when you're a two-person team.


Building an affiliate platform or something similar? What's your approach to the "should we move money" question? I'd love to hear how others have drawn that line.

Building jo4.io — affiliate marketplace infrastructure where the platform never touches a dollar.

Top comments (0)