@affiliate-coach/campaigns
The Creator Campaigns engine: everything between a brand's brief and the bounty row the payout run pays. Pure TypeScript over the ledger's Sql; no HTTP, no UI. The admin, portal and API call it; nothing else writes the campaign tables.
The contract it was built against is build-plan/04-creator-campaigns.md; the requirements are PRD-Creator-Campaigns.md Part B; the decisions are 55–60 and 67–106 in DECISIONS-FOR-REVIEW.md. Schema: 0017_campaigns, 0018_funding_endpoint, 0019_campaigns_vocabulary.
The shape of a campaign
brief (campaign + deliverables)
└─ application (snapshot frozen at apply)
└─ deal (frozen SOW; escrow held from the brand balance)
└─ task per deliverable: draft → review → post → verify
└─ bounty commission row → payout run (packages/payouts)
- Brand balance is an append-only ledger per brand (
brand_balance_entries). Funding in;escrow_holdat acceptance; at verification anescrow_releasefor the consumed hold beside thebountyand theplatform_fee.brandBalance()returnsfreeCents(Σ entries),reservedCents(open holds − releases) andbalanceCents(free + reserved). "Can this brand pay" readsfreeCents. - Gift value lives on the deal and is reported on the 1099 total; it never becomes a commission row.
- Eligibility is evaluated here, in the app, so a screen can show an ineligible brief dimmed with its one plain-words reason.
Modules
| File | What it holds |
|---|---|
rules.ts |
Pure: tierFor, evaluateEligibility (one reason), qualityScore (0–100 with reasons; good / check / weak), rateGuidance, paysWords ("$150 + 25%"), asksWords ("1 Reel · by Nov 2"), sowMarkdown, businessDaysAfter |
read.ts |
loadCampaign, creatorFacts, brandBalance, row loaders |
brand.ts |
Brief lifecycle (createCampaign → publishCampaign → closeCampaign), invites, applicants (shortlist, counterOffer, decline, accept → deal + tasks + enrollment + escrow), fundWaitingDeals, reviewDraft, autoApproveStaleDrafts (10 business days), approveUnverifiable, cancelDeal, decideDispute, recordFunding (idempotent on the Stripe ref), refundFunding |
creator.ts |
upsertCreatorProfile, apply, withdraw, respondToCounter, acceptSow, submitDraft, acknowledgeDisclosure, markPosted (post window enforced), openDispute, appealDispute |
verify.ts |
verifyTask (tags, handles, disclosure, label → bounty + brand spend + rights grant), verificationSweep, captureMetrics, markDealsPaid, flagDeletedPosts, rightsReminders; the PostLookup port the API binds to the aggregator |
reputation.ts |
creatorReputation ("4 deals · 100% on time"), brandReputation |
errors.ts |
CampaignError with its code union; messages are written to be shown |
Every state-changing call writes an audit_log row and sets the ledger trigger context (affiliate_coach.source = 'campaigns') before touching commissions.
Using it
import { apply, accept, verifyTask, brandBalance } from '@affiliate-coach/campaigns';
const { applicationId } = await apply(sql, { workspaceId, affiliateId, campaignId, pitch: 'Three reels a week, mostly mornings.' });
const { dealId, status } = await accept(sql, { workspaceId, actorUserId, applicationId, flatFeeCents: 15000n });
// status is 'funded' when the brand's free balance covered the fee, else 'unfunded' (the Queue says so; fundWaitingDeals() retries)
Errors are CampaignError instances; check err.code (for example ineligible, not_open, bid_over_cap, outside_window, balance_short) and show err.message.
Tests
npx vitest run in this package: 37 tests on PGlite through @affiliate-coach/db/test-harness, with test/support.ts building a brief, a funded brand and a creator profile. npx tsc --noEmit is clean.
Rendered from packages/campaigns/README.md. Edit the source, not this page.