04 — Creator Campaigns: the build contract
Authority: PRD-Creator-Campaigns.md Part B, decisions 55–60 and 67–75 in DECISIONS-FOR-REVIEW.md, and migration packages/db/supabase/migrations/0017_campaigns.sql (already applied to the schema tests; do not edit it — a change is a new migration 0017+ and you must say so in your report).
Four streams build in parallel against this contract. The engine stream owns the signatures below; the admin, portal and API streams call them exactly as written. If a signature must change, the engine stream changes this file in its branch and names the change in its report; the integrator reconciles.
1. Package @affiliate-coach/campaigns (packages/campaigns)
Mirror packages/payouts in shape: src/index.ts exports, type: module, deps on @affiliate-coach/ledger (for Sql, cents), @affiliate-coach/core; devDeps @affiliate-coach/db (test harness), pglite, vitest. Tests run on PGlite via @affiliate-coach/db/test-harness + test-fixtures (the seed() there gives WS, product, program, sponsor/recruit, one conversion; add a campaignSeed(db) in the package's own test/support.ts that creates a disclosure block, an open brief with one Instagram reel deliverable, funding of $500 for the product, and a creator profile for the recruit).
All functions take (sql: Sql, input) and return plain objects. Money is bigint cents. Errors are CampaignError with a code (string literal union) and a message a screen can show. Every write that changes state writes an audit_log row (actor_kind 'admin' | 'affiliate' | 'system') with object_table and object_id, and sets the ledger trigger context (affiliate_coach.source = 'campaigns') when it writes commissions.
1.1 Pure (no SQL) — src/rules.ts
export type Tier = 'nano' | 'micro' | 'mid' | 'macro' | 'mega';
export function tierFor(followers: number): Tier; // <10k nano · 10k–50k micro · 50k–500k mid · 500k–1M macro · ≥1M mega
export type Eligibility = { // = campaigns.eligibility jsonb, every key optional
platforms?: string[]; min_followers?: Record<string, number>; min_engagement_bp?: number; categories?: string[];
countries?: string[]; languages?: string[]; min_quality_score?: number; requires_enrollment?: boolean;
min_completed_deals?: number; min_wall_sales?: number;
};
export type CreatorFacts = {
profiles: Array<{ platform: string; followers: number | null; engagement_bp: number | null; categories: string[]; country: string | null; language: string | null; quality_score: number | null; source: 'aggregator' | 'manual' }>;
enrolledInProduct: boolean; completedDeals: number; wallSales: number;
exclusivities: Array<{ categories: string[]; until: Date }>; // from the creator's open deals' campaigns
};
export type EligibilityResult = { eligible: true } | { eligible: false; reason: string }; // ONE reason, plain words: "needs 1,000 followers on Instagram"
export function evaluateEligibility(e: Eligibility, facts: CreatorFacts, campaign: { exclusivity_categories: string[]; now: Date }): EligibilityResult;
export type QualityInput = { followers: number | null; engagement_bp: number | null; avg_reach: number | null; follower_growth_30d_pct?: number | null; comments_per_like?: number | null; country: string | null; audience_top_country?: string | null };
export type Quality = { score: number; word: 'good' | 'check' | 'weak'; reasons: string[] };
export function qualityScore(platform: string, q: QualityInput): Quality; // PRD B.8 v1; benchmarks in A1.5 (IG nano 1.7–6 % etc.); null inputs reduce confidence, never crash; word: ≥70 good, 40–69 check, <40 weak
export function rateGuidance(platform: string, format: string, followers: number | null): { lowCents: bigint; highCents: bigint; sentence: string }; // "creators your size usually get $75–$200 for this" (Collabstr 2025 nano/micro $50–$300)
export function paysWords(c: { flat_fee_cents: bigint | null; bids_allowed: boolean; bid_cap_cents: bigint | null; program_rate: string | null; gifting: Record<string, unknown>; bonus: unknown[] }): string; // "$150 + 25%" · "Bid up to $300" · "Gift · $89 kit"
export function asksWords(deliverables: Array<{ platform: string; format: string; count: number; post_window_end: Date | null }>): string; // "1 Reel · by Nov 2"
export function sowMarkdown(input: { brand: string; creator: string; campaign: CampaignRow; deliverables: DeliverableRow[]; flatFeeCents: bigint; giftValueCents: bigint; giftDescription: string | null; platformFeeBp: number; autoApproveDrafts: boolean; disclosureShort: string }): string; // the SOW text; includes decision 59's auto-approve clause, rights, exclusivity, FTC clauses (no sentiment requirement, no AI testimonials), payment timing
export function businessDaysAfter(from: Date, days: number): Date; // for the 10-business-day auto-approve
1.2 Reads — src/read.ts
export function loadCampaign(sql, id): Promise<CampaignRow & { deliverables: DeliverableRow[]; product: { id; name; slug; mark_color } }>;
export function creatorFacts(sql, workspaceId, affiliateId): Promise<CreatorFacts>;
export function brandBalance(sql, workspaceId, productId): Promise<{ balanceCents: bigint; reservedCents: bigint; freeCents: bigint; fundedCents: bigint; paidOutCents: bigint; feesCents: bigint }>;
1.3 Brand side (admin) — src/brand.ts
export function createCampaign(sql, { workspaceId, actorUserId, productId, title }): Promise<{ campaignId }>; // draft
export function updateCampaign(sql, { workspaceId, actorUserId, campaignId, patch: Partial<CampaignEditable>, deliverables?: DeliverableInput[] }): Promise<void>; // validates eligibility object keys, pairs (usage/exclusivity/bids); replaces deliverables when given
export function publishCampaign(sql, { workspaceId, actorUserId, campaignId }): Promise<void>; // draft → open; CampaignError 'needs_disclosure' | 'needs_pay' | 'needs_deliverable'
export function closeCampaign(sql, { workspaceId, actorUserId, campaignId, archive?: boolean }): Promise<void>;
export function inviteCreators(sql, { workspaceId, actorUserId, campaignId, affiliateIds }): Promise<{ invited: number }>;
export function shortlist(sql, { workspaceId, actorUserId, applicationId }): Promise<void>;
export function counterOffer(sql, { workspaceId, actorUserId, applicationId, counter: { flat_fee_cents?: bigint; note?: string } }): Promise<void>; // one round each way (counter_rounds)
export function decline(sql, { workspaceId, actorUserId, applicationId, reason }): Promise<void>; // reason required; creator sees it
export function accept(sql, { workspaceId, actorUserId, applicationId, flatFeeCents?: bigint, giftValueCents?: bigint, giftDescription?: string }): Promise<{ dealId; status: 'funded' | 'unfunded' }>;
// → application accepted; deal created with frozen sow + sow_md; tasks created (one per deliverable × count); affiliate enrolled in the campaign's program if any (insert affiliate_programs + terms acceptance as the portal's enroll does; the 0013 trigger mints the link);
// escrow: if free balance ≥ flat fee → brand_balance_entries escrow_hold and deal 'funded', else deal stays 'unfunded' (queue item). Decision 69.
export function fundWaitingDeals(sql, { workspaceId }): Promise<{ funded: string[] }>; // sweep: unfunded deals whose brand now has free balance → hold + funded
export function reviewDraft(sql, { workspaceId, actorUserId, taskId, decision: 'approve' | 'changes', note? }): Promise<void>; // changes requires note; ≤2 rounds then 'approve' only
export function autoApproveStaleDrafts(sql, { workspaceId, now }): Promise<{ approved: string[] }>; // decision 59: draft_submitted older than 10 business days on campaigns with auto_approve_drafts → draft_approved with task_reviews decision 'auto_approve'
export function approveUnverifiable(sql, { workspaceId, actorUserId, taskId, note? }): Promise<void>; // brand approval where verification is impossible → same as verifyTask
export function cancelDeal(sql, { workspaceId, actorUserId, dealId, reason }): Promise<void>; // escrow_release; tasks left as they are; cannot cancel 'verified'/'paid'
export function decideDispute(sql, { workspaceId, actorUserId, disputeId, decision: 'release' | 'refund' | 'split', note, splitCents? }): Promise<void>;
export function recordFunding(sql, { workspaceId, productId, amountCents, stripeRef, pending?: boolean }): Promise<{ entryId: string | null; duplicate: boolean }>; // idempotent on stripeRef; pending=true (ACH not settled) writes NO entry but returns duplicate:false — the API decides; after settle call again with pending false
export function refundFunding(sql, { workspaceId, actorUserId, productId, amountCents, stripeRef, reason }): Promise<void>; // negative adjustment; refuses if free balance < amount
1.4 Creator side (portal) — src/creator.ts
export function upsertCreatorProfile(sql, { workspaceId, affiliateId, platform, handle?, followers?, engagement_bp?, avg_reach?, categories?, country?, language?, source: 'manual' | 'aggregator' }): Promise<void>; // recomputes quality_score/word/reasons
export function apply(sql, { workspaceId, affiliateId, campaignId, pitch?, bidCents? }): Promise<{ applicationId }>;
// CampaignError 'not_open' | 'not_invited' | 'ineligible' (message = the reason) | 'bid_not_allowed' | 'bid_over_cap' | 'already_applied' | 'exclusivity'; snapshot = profiles + facts at apply time
export function withdraw(sql, { workspaceId, affiliateId, applicationId }): Promise<void>;
export function respondToCounter(sql, { workspaceId, affiliateId, applicationId, accept: boolean }): Promise<void>; // accept → application stays 'countered' until the brand accepts; semantics: creator agreement recorded in counter.creator_accepted_at
export function acceptSow(sql, { workspaceId, affiliateId, dealId }): Promise<void>; // sow_accepted_creator_at; funded deal → in_progress when both accepted
export function submitDraft(sql, { workspaceId, affiliateId, taskId, draftUrl?, storagePath?, note? }): Promise<void>;
export function acknowledgeDisclosure(sql, { workspaceId, affiliateId, taskId }): Promise<void>;
export function markPosted(sql, { workspaceId, affiliateId, taskId, postUrl, publishJobId? }): Promise<void>; // requires disclosure_ack_at; inside post window (CampaignError 'outside_window')
export function openDispute(sql, { workspaceId, affiliateId?, dealId, openedBy: 'creator' | 'brand', reason }): Promise<{ disputeId }>;
export function appealDispute(sql, { workspaceId, affiliateId, disputeId, note }): Promise<void>;
1.5 Verification and money — src/verify.ts
export type PostLookup = { // port; the API binds an aggregator adapter, tests a fake
fetchPost(input: { affiliateId: string; platform: string; postUrl: string | null; publishJobId: string | null }): Promise<{ found: boolean; url: string | null; caption: string | null; paidPartnershipLabel: boolean | null; metrics: Record<string, number> | null } >;
};
export function verifyTask(sql, { workspaceId, taskId, by: 'worker' | 'brand', actorUserId?, found: { url; caption; label; metrics } | null, requiredTags: string[], requiredHandles: string[], disclosureShort: string }): Promise<{ outcome: 'verified' | 'failed_verification'; bountyCommissionId: string | null }>;
// verified → task verified → bounty commission (kind 'bounty', amount = deal.flat_fee_cents / number of tasks, floored, remainder on the last task; born approved unless campaign.bounty_hold_days > 0 → pending with hold_until) → brand_balance bounty + platform_fee entries → rights_grants row if usage_rights ≠ none (starts_at = posted_at, expires_at = + usage_months) → task completed → when every task is completed: deal 'delivered' → 'verified' → 'paid' is set by the payout run? NO: deal 'paid' is set by `markDealsPaid` below.
// missing label/disclosure → failed_verification with verification_note (fix-it step, not a penalty; PRD B.10)
export function verificationSweep(sql, lookup: PostLookup, { workspaceId, now }): Promise<{ checked: number; verified: number; failed: number }>; // tasks in awaiting_post/posted_unverified inside or after their window
export function captureMetrics(sql, lookup, { workspaceId, now }): Promise<{ updated: number }>; // 24h/72h/7d/30d buckets into deal_tasks.metrics
export function markDealsPaid(sql, { workspaceId }): Promise<{ paid: string[] }>; // deals 'verified' whose bounty rows are all state 'paid' → 'paid'
export function flagDeletedPosts(sql, lookup, { workspaceId, now }): Promise<{ flagged: string[] }>; // completed tasks inside the usage window whose post is gone → fraud_flags kind 'manual' + bounty row flagged (PRD B.12); admin decides the reversal
export function rightsReminders(sql, { workspaceId, now }): Promise<{ due: Array<{ dealId; expiresAt }> }>; // 14 days before expiry, once (reminder_sent_at)
1.6 Reputation — src/reputation.ts
export function creatorReputation(sql, affiliateId): Promise<{ deals: number; onTimePct: number | null; line: string }>; // "4 deals · 100% on time"
export function brandReputation(sql, productId): Promise<{ deals: number; medianReviewHours: number | null; lateApprovals: number }>;
1.7 Engine stream notes (built Oct 19) — what differs from the text above
All signatures above are implemented as written; these are the additions and the places where the schema forced a call. Decisions 76–86.
- Migration
0019_campaigns_vocabulary.sql(withpackages/db/test/0019_campaigns_vocabulary.test.ts):commission_events.sourceadmitscampaigns;fraud_flags.kindadmitspost_deleted(used byflagDeletedPostsinstead ofmanual, which 0008 removed);brand_balances.reserved_centsis redefined as holds − releases over open deals (decision 76). 0016 is untouched. - Balance semantics (decision 76):
brandBalance()returnsfreeCents= Σ entries (the view'sbalance_cents),reservedCentsfrom the view,balanceCents= free + reserved. The engine writes anescrow_releasefor the consumed hold alongside eachbounty+platform_fee. ReadfreeCentsfor "can this brand pay"; the strip is free · reserved · funded as before. - Additive type changes:
CreatorFacts.exclusivities[]gains optionalbrand?: string(the reason names it);creatorFacts(sql, workspaceId, affiliateId, productId?)takes an optional product to scopeenrolledInProduct(apply passes the campaign's product; omitted = any program in the workspace);upsertCreatorProfileaccepts optionalfollower_growth_30d_pct,comments_per_like,audience_top_countryfor aggregator refreshes. - New error codes beyond the ones listed:
program_has_no_terms(accept),has_deals(updateCampaign replacing deliverables after a deal exists),invalid_pair,gift_needs_description,review_rounds_exhausted,needs_post_url,deal_disputed,split_needs_amount,split_not_possible,balance_short,sow_not_accepted,deal_not_active,draft_needs_content,draft_required,dispute_already_open,dispute_window_closed,dispute_not_appealable. Full union insrc/errors.ts. - Also exported for the other streams:
captionChecks,bountySplit,giftValueOf,usd,pct,shortDate,platformName,formatName,validateEligibility,loadDeal/loadTask/loadDealTasks/loadApplication/loadDeliverables, and the row types (CampaignRow,DealRow,TaskRow,ApplicationRow, …). - Audit rows on the creator path carry
actor_kind 'affiliate'withactornull and the affiliate id insideafter(there is no auth actor on the service path).
2. Admin (apps/admin) — the Campaigns section
Nav item Campaigns between Collateral and Payouts. Screens (same shell, same card/dense table idiom, same action-kit pattern gate → lib call → back()):
/campaigns— list: title, product mark, status, applicants, hired/target, delivered, spend/budget, apply-until; Balance strip per brand (free · reserved · funded) with Add funds (opens Stripe Checkout via a server action usingpayoutStripeClient();success_url/campaigns?funded=1,cancel_url/campaigns; metadata{workspace_id, product_id};payment_method_types: ['card','us_bank_account']; minimum $100; the entry is written by the API webhook, so after success the strip shows "pending settlement" until the webhook lands) and "Brand pays the processing fee" line (decision 73)./campaigns/newand/campaigns/[id]/edit— the ten-minute builder in PRD B.7 order: who (eligibility, with a live eligible-creators count computed withevaluateEligibilityover the workspace's creator_profiles), what (deliverables + collateral attached from the vault), how much (flat / bids+cap / commission program / gifting / bonus; therateGuidanceline), rights, dates, budget, publish or save. Preview as a creator (render the Wall tile words viapaysWords/asksWords)./campaigns/[id]— detail: applicants ranked (fit = eligibility passed + quality word + snapshot followers + reputation), accept (with gift value/description fields) / decline (reason) / counter; bulk accept; deals table with status and next action; deliverables review queue (draft preview link, approve / request changes with note, round counter, auto-approve countdown); verification status + metrics; disputes; rights grants with expiry; per-campaign report (cost, CPE, CPV, attributed sales via existing attribution where program_id set, creator leaderboard) with CSV.- Queue (
lib/admin/queue.ts) gains kinds:dispute,failed_verification,unfunded_deal,weak_applicant(quality word weak on an open application). Drawer resolves each to the campaign detail. - Reports —
paidByRecipientadds agiftValueCentscolumn (Σ deals.gift_value_cents for deals 'verified'/'paid' whose completed_at falls in the year) and the CSV/1099 total shows paid + gift (decision 67).
Tests in apps/admin/test/campaigns.test.ts on bootAdmin(): list/detail reads, Checkout session params (pure builder), queue kinds, report gift column. Typecheck clean.
3. Portal (apps/portal) — creator side
- Wall: an Open calls row above the brand rows when
my_campaignshas rows. Tile = same 4:5 geometry (components/wall/Tile.tsxidiom) with the product tint; poster line =paysWords; foot chip =asksWords; dimmed (opacity .45, no focus ring) whenevaluateEligibilityfails, with the one reason under the title. Tap → Campaign sheet (Sheetcomponent): brief, deliverables, rights in plain words, rate guidance, Apply (disabled with the reason when ineligible). Data:lib/wall/campaigns.tsreadsmy_campaigns,my_campaign_deliverables,my_creator_profiles,my_applications,my_deals; facts via service-rolecreatorFacts. - Apply sheet: connected accounts (from
my_social_connections) shown; Your numbers per platform (handle, followers, engagement %) editable →upsertCreatorProfile(source 'manual')with the "unverified until connected" note; pitch (≤300, optional); bid (when allowed, cap shown); disclosure + rights acknowledgement checkbox; Apply →apply(); confirmation "You'll hear back by {apply_until}". Errors are the CampaignError message. - Studio → Deals (
/studio/deals, segment or section in StudioScreen): one card per deal with the next action ("Accept the agreement", "Upload draft", "Post by Thursday", "We're checking the post", "Paid Nov 1", "Changes requested: …"); SOW sheet with Accept →acceptSow; draft upload (file to thecollateralbucket at/{ws}/deals/{deal}/{task}/via signed upload URL, or a link) →submitDraft; change requests inline frommy_task_reviews; Post it reuses the Composer with the post window enforced (cannot schedule outsidepost_window_start..end; the composer's existing preflight gets awindowarg) and the collateral/teleprompter one tap away (/studio/record/<asset>when the deliverable hascollateral_asset_id); after posting →markPosted(post_url from the publish job'sexternal_urlwhen Post-for-me, typed when Copy & post); disclosure acknowledgement checkbox before Post it →acknowledgeDisclosure. Dispute: "Something wrong?" →openDispute(within 7 days of delivery) and appeal. - Earnings:
lib/earnings/ledger.tslearns kindbounty("Reel for HealthyApp · $150 · verified Oct 20 · pays Nov 1"; the why-lines name the deal); gift shows on the Deals card as "gift · $89 · not cash". Tab count for Studio includes open deals. - Reputation line on the profile/settings:
creatorReputation. PortalContextis unchanged; read deals/campaign counts in the screens' loaders.
Tests in apps/portal/test/campaigns.test.ts: pure words/next-action mapping, window enforcement, ledger bounty words. Device: design and check at 390 first (clients are on mobile).
4. API (apps/api)
POST /api/stripe/funding— own-account webhook on the payout Stripe account (STRIPE_FUNDING_WEBHOOK_SECRET). Verify signature; insert intostripe_webhook_events(endpoint'funding'— add the value to the endpoint check if 0006 constrains it: that is a migration 0017; say so) first for idempotency;checkout.session.completedwithpayment_status = 'paid'→recordFunding(amount =amount_total, ref = session id, metadata workspace/product);payment_status = 'unpaid'(ACH) → recorded as pending, no entry;checkout.session.async_payment_succeeded→recordFunding;async_payment_failed→ audit only;charge.refunded→ audit (admin doesrefundFundingby hand); refuse events carryingaccount(those belong to/connect).GET|POST /api/cron/campaignsevery 15 min (add tovercel.json): per workspacefundWaitingDeals,autoApproveStaleDrafts,verificationSweep+captureMetricswith the aggregator adapter (PostLookupover the publishing package'sAyrshareClient.postStatuswhen the task has apublish_job_id; otherwisefoundfrom the typedpost_urlonly — label unknown →null),markDealsPaid,flagDeletedPosts,rightsReminders(audit row only; mail is not wired). Honest no-key state: withoutAYRSHARE_API_KEYthe sweep still verifies by URL presence and says so in the report.- Wire through
lib/handlers.tsthe same way payouts are; tests inapps/api/test/campaigns.test.tswith a signed fake event (seepackages/payouts/test/support.tsconnectEvent/signedHeaderfor the pattern) and a fakePostLookup. .env.example:STRIPE_FUNDING_WEBHOOK_SECRET.
5. Rules for every stream
- Don't edit 0016. New schema = 0017+ with a test, and say so.
- Don't touch another stream's directories except the two shared files you must:
apps/docs/docs.json(add your screens/docs under acampaignsgroup; keep JSON valid) andDECISIONS-FOR-REVIEW.md(append numbered decisions ≥ 76, one line each, under a## Creator Campaigns build (Oct 19 → )heading that already exists). - Floor, never round.
workspace_idon every row. Composite FKs are in the schema; respect them in inserts. - Finish with
npx vitest rungreen in your package/app andnpx tsc --noEmitclean there. Report: what you built, what you changed in this contract, what is left.
Rendered from build-plan/04-creator-campaigns.md. Edit the source, not this page.