@ezyyeah/affiliate-tracking
v0.1.0
Published
A Convex component for affiliate tracking, attribution, commissions, payouts, and analytics.
Maintainers
Readme
@ezyyeah/affiliate-tracking
Affiliate tracking for Convex: programs, affiliates, campaigns, referral links, click tracking, conversion attribution, commissions, payouts, audit events, cleanup, and daily analytics counters.
This package is component-first. It owns its isolated Convex tables, while your app keeps authentication, authorization, billing, checkout, and user records in the host app. Host functions pass app-resolved user, visitor, admin, and order identifiers into the component as strings.
Install
npm install @ezyyeah/affiliate-trackingMount the component in your Convex app config:
import affiliateTracking from "@ezyyeah/affiliate-tracking/convex.config.js";
import { defineApp } from "convex/server";
const app = defineApp();
app.use(affiliateTracking);
export default app;Run Convex codegen after registering the component:
npx convex dev --typecheck-componentsClient Wrapper
Create a small host-side wrapper after codegen has generated components for your app:
import { AffiliateTracking } from "@ezyyeah/affiliate-tracking";
import { components } from "./_generated/api";
export const affiliates = new AffiliateTracking(components.affiliateTracking);The wrapper is intentionally thin. It gives you typed, ergonomic calls from host Convex functions while leaving auth and business policy in your app.
Usage
Create or update a program from a host mutation after checking that the caller is allowed to manage affiliate settings:
const programId = await affiliates.upsertProgram(ctx, {
slug: "default",
name: "Default Program",
defaultCommissionType: "percentage",
defaultCommissionValue: 20,
defaultCurrency: "USD",
attributionWindowMs: 30 * 24 * 60 * 60 * 1000,
clickDeduplicationMode: "dedupeKey",
clickDeduplicationMs: 30 * 60 * 1000,
conversionLimitMode: "none",
actorId: adminId,
});Enroll an affiliate and create a link:
const affiliateId = await affiliates.upsertAffiliate(ctx, {
programId,
externalId: userId,
status: "active",
actorId: adminId,
});
await affiliates.upsertLink(ctx, {
programId,
affiliateId,
code: "creator-20",
destinationUrl: "https://convex.dev/pricing",
actorId: adminId,
});Track clicks from a host HTTP action and redirect the visitor:
const result = await ctx.runMutation(
components.affiliateTracking.tracking.trackClick,
{
programId,
code,
visitorId,
dedupeKey: `${programId}:${code}:${visitorId}`,
source: "newsletter",
referrer: request.headers.get("referer") ?? undefined,
userAgent: request.headers.get("user-agent") ?? undefined,
},
);
return Response.redirect(result.destinationUrl, 302);Record purchases with an idempotency key. The component attributes to an explicit click, an explicit link code, or the latest valid visitor click inside the program attribution window.
await affiliates.recordConversion(ctx, {
programId,
externalId: orderId,
idempotencyKey: `order:${orderId}`,
visitorId,
customerId,
amountCents: 12_500,
currency: "USD",
});Approve conversions and create payout batches from approved commissions:
await affiliates.updateConversionStatus(ctx, {
conversionId,
status: "approved",
actorId: financeAdminId,
});
const payoutId = await affiliates.createPayoutBatch(ctx, {
programId,
currency: "USD",
actorId: financeAdminId,
});Attribution Model
Each program controls how clicks and conversions are deduplicated:
attributionWindowMs: how long a click can receive conversion credit.clickDeduplicationMode:"dedupeKey","visitor","visitorAndLink", or"none".clickDeduplicationMs: rolling duplicate click window.conversionLimitMode:"none","visitor","customer", or"customerOrVisitor".conversionLimitMs: rolling duplicate conversion window for the selected conversion rule.
recordConversion() is retry-safe through idempotency records. Reusing the same idempotencyKey returns the existing conversion result instead of double-counting commissions.
Commission Model
Programs define default commission settings:
defaultCommissionType:"percentage"or"fixed".defaultCommissionValue: percentage points or fixed minor units, depending on type.defaultCurrency: the expected commission currency.
Campaigns can override the program commission type and value. Conversions store their calculated commission and can move through pending, approved, rejected, and paid operational states.
Public API
Wrapper methods:
upsertProgram(ctx, args)getProgram(ctx, args)upsertAffiliate(ctx, args)upsertCampaign(ctx, args)upsertLink(ctx, args)trackClick(ctx, args)recordConversion(ctx, args)updateConversionStatus(ctx, args)updateCommissionStatus(ctx, args)createPayoutBatch(ctx, args)updatePayoutStatus(ctx, args)getSummary(ctx, args)cleanupExpired(ctx, args)resetProgramData(ctx, args)
Component modules:
programs:upsertProgram,getProgram,listPrograms,updateProgramStatusaffiliates:upsertAffiliate,getAffiliate,listAffiliates,updateAffiliateStatuscampaigns:upsertCampaign,getCampaign,listCampaigns,updateCampaignStatuslinks:createLink,upsertLink,getLink,resolveLink,listLinks,updateLinkStatustracking:trackClick,recordConversion,getConversion,listConversionscommissions:updateCommissionStatus,updateConversionStatus,listCommissionspayouts:createPayoutBatch,updatePayoutStatus,getPayout,listPayableCommissions,listPayoutsanalytics:getSummary,getDailyBreakdownmaintenance:cleanupExpired,resetProgramData
Analytics
Clicks and conversions update daily sharded counters for program, affiliate, campaign, and link scopes through @convex-dev/sharded-counter.
Use getSummary for aggregate dashboard numbers and getDailyBreakdown for bounded date ranges. Analytics read exact per-day counter values, so keep dashboard ranges intentionally bounded.
Operational Notes
- Host app auth is intentionally not embedded. Check permissions in your app, then pass
actorIdfor auditability. - IDs at the component boundary are strings in generated host APIs, as expected for Convex components.
- All public component functions validate arguments with Convex validators.
cleanupExpiredremoves expired clicks and idempotency records and is safe to call from a cron or admin mutation.resetProgramDatais destructive and clears a program's component-owned records and counter totals.- Payout creation only includes approved commissions that are not already attached to a payout.
Test Helper
Package consumers can use the test helper with convex-test:
import {
api,
initAffiliateTrackingTest,
} from "@ezyyeah/affiliate-tracking/test";
const t = initAffiliateTrackingTest();
const programId = await t.mutation(api.programs.upsertProgram, {
slug: "default",
name: "Default Program",
defaultCommissionType: "percentage",
defaultCommissionValue: 20,
defaultCurrency: "USD",
});Package Exports
@ezyyeah/affiliate-tracking@ezyyeah/affiliate-tracking/convex.config.js@ezyyeah/affiliate-tracking/convex.config@ezyyeah/affiliate-tracking/_generated/component.js@ezyyeah/affiliate-tracking/_generated/component@ezyyeah/affiliate-tracking/test
Local Example
The repository includes a runnable Vite + React example app in examples. It demonstrates program creation and selection, affiliate enrollment, link creation, redirect tracking, conversion recording, analytics, approvals, payout creation, paid payout marking, and cleanup.
From the repository root:
pnpm install
pnpm build
pnpm devThat starts:
convex dev --typecheck-componentsfor the example backend inexamples/convex- Vite on http://127.0.0.1:5173
Convex writes VITE_CONVEX_URL to examples/.env.local. If your deployment site URL cannot be derived from the Convex URL, add:
VITE_CONVEX_SITE_URL=https://your-deployment.convex.siteLocal Development
pnpm install
pnpm typecheck
pnpm test
pnpm build
pnpm packRun pnpm codegen only after configuring a Convex deployment or inside a host app that has registered the component. Deployment-backed smoke checks still require a Convex project because packaged components need app-side codegen after registration.
