Fundraising
How a group runs a salsa fundraiser — campaigns, participants, pricing, commission, and the portal they manage it from.
Fundraising
A fundraiser is a group — a school, a team, a church — selling salsa for a period of time and keeping a share of what they raise. It is a first-class part of the platform, not a bolt-on: campaigns have their own pages, their own participants, their own checkout path, and their own portal.
The shape of a campaign
Fundraiser is the campaign. It carries the organisation, the coordinator's contact
details, a startDate/endDate window, an optional goal, a commissionRate
(percentage, 50.00 by default) and a defaultUnitPrice (10.00 by default) —
the last two are what make each campaign its own store.
| Status | Meaning |
|---|---|
DRAFT | Being set up; not public |
ACTIVE | Live and selling |
ENDED | Past its end date |
CANCELLED | Called off |
Each campaign gets a slug (/fundraise/[slug]) and may also get a subdomain for
its own branded entry point. pageConfig, logoUrl, coverPhotoUrl,
missionStatement and bio drive the campaign page, which the coordinator edits
themselves in the portal.
Rollups — totalOrders, totalRevenue, totalCommission — are maintained on the
campaign rather than recomputed per request.
Participants
FundraiserParticipant is one seller within a campaign. Each gets a unique
referralCode; an order attributed to that code counts toward both the
participant's and the campaign's totals.
lastMilestoneNotified records the highest sales milestone a participant has
already been congratulated for. It is a marker rather than a derived value, because
domain events are delivered at least once — without it, a replayed
payment.completed sends a second "you reached 25 sales" email, and a burst of
orders sends one message per threshold crossed instead of one message.
Participants can be grouped into FundraiserTeams, and campaigns can belong to a
FundraiserSeason.
How a group runs its drive
Every campaign is one of two shapes, set on Fundraiser.fulfillmentMethod:
| Method | What it means |
|---|---|
ORDER_FORMS_AND_BULK | The default. The group hands out brochures, collects the forms and the money face to face, and takes one delivery at the close to distribute. |
ONLINE_ONLY | No paper drive. The link is the whole campaign. |
ONLINE_ONLY is not the opposite of "the store is open". The online link is live on
every campaign, whichever method is set — a supporter who would rather not fill in a paper
form still needs somewhere to buy. The method only records whether the group is also
collecting order forms. ONLINE_STORE_ALWAYS_AVAILABLE in
lib/fundraising/fulfillment.ts exists to say so where someone will read it.
An online order never joins the bulk delivery. It is already paid for, already taxed, and already going to the buyer's own address — so the coordinator hands out only what arrived in their delivery, which is exactly what their order forms account for.
The two channels also point the money in opposite directions. On the online side we are the
seller: we take the payment, credit the group its share, and the buyer pays shipping and sales
tax at checkout. On the collected-forms side the group is a reseller, not our agent — they
have already taken the money, so at the close they are invoiced wholesale for our share plus
the delivery and brochure fees, and sales tax on their resales is theirs. That invoice is only
tax-free with a resale certificate on file, which is why resaleNumber is asked for at signup
and shown in the admin.
brochureFee and bulkDeliveryFee are snapshotted per campaign rather than read from a global
setting, for the same reason Order.unitCost is: changing the standard fee must not re-price a
drive that is already running.
The choice is captured in three places — the school picks on the signup form
(requestedFulfillment on FundraiserSignupRequest), the admin confirms or overrides at
approval, and the admin can change it afterwards under Manage → Fulfillment. The precedence
is one function, resolveFulfillmentTerms(): the admin wins, the school's request is the
fallback, and collecting order forms is the default. All the school-facing copy lives beside it
in the same module rather than being retyped into each form.
Each campaign is its own store
A fundraiser is a self-contained shop. It sells the same salsa the retail storefront does, but at its own price, from its own shelf, and it keeps its own share. Several campaigns run at once, each selling the same jar at a different price with the proceeds split a different way.
lib/fundraising/store.server.ts is the single place a store is resolved.
resolveFundraiserStore({ fundraiserSlug, referralCode }) returns the campaign's
prices, catalogue, commission rate and (when a participant link was used) the
participant to credit. Every fundraiser page and all three checkout routes go
through it, so what the supporter is quoted is what they are charged.
The slug is what identifies the store, not the participant referral code. That was the bug: attribution used to hang off the code alone, so a supporter who shopped from the campaign page itself carried none — their cart was priced from the retail catalogue and the sale was recorded against nobody.
A cart holds one store's goods at a time. cartStoreContext() in
lib/store/cart.ts reads the store off the lines and addItem() refuses to mix
them; the customer is offered a fresh cart instead. A mixed cart has no single price
list, no single group to credit, and no single thank-you page to land on.
Pricing
A fundraiser sells above retail. The group's share comes out of the gap between the fundraiser price and the catalogue price — not out of the margin on the jar. By default a jar sells for $10 and the split is 50/50.
Two prices can apply, in this order:
FundraiserProduct.price— this campaign's price for this one product.Fundraiser.defaultUnitPrice— the campaign's store price. Defaults to10.00.
The retail catalogue price is deliberately not a third fallback. A campaign that
has set neither still sells at its store price; falling through to Product.price
is what used to put a jar quoted at ten dollars into the cart at nine.
Resolve price through fundraiserUnitPrice() in lib/fundraising/pricing.ts — and
server-side, through priceInStore(), which also refuses a product the campaign
does not carry rather than quietly charging retail for it.
The rule is deliberately ?? and not a truthiness check: an override of zero is
a campaign giving something away, and falling through would charge for it.
A campaign that has curated no FundraiserProduct rows sells the whole active
catalogue at its store price. An empty catalogue is not an empty shop.
Commission
lib/fundraising/commission.ts is the live implementation. The rate is stored as a
percentage on Fundraiser.commissionRate (50.00, not 0.50).
commissionBase = max(0, subtotal − discountAmount)
commission = round(commissionBase × rate / 100, 2)Two things the base deliberately excludes:
- Shipping and tax. Shipping is money owed to a carrier and tax is money owed to
a state. Paying a share of either comes straight out of the gap that funds the
group's share in the first place. This used to be computed from
order.total; on a 50% campaign, a $10 jar shipped for $6 with tax paid out about $8 to the group instead of $5. - Gift certificates. A certificate is a means of payment, not a lower price, so it does not reduce the base. Discount codes do, because a promo genuinely reduces what was earned on the goods.
A campaign's "goal" and "raised" figures mean gross sales, not commission. This reads backwards the first time you see it and it is not a bug — do not "fix" it.
lib/fundraising/calculate-commission.ts implements the opposite (fractional)
convention and has no callers. Do not import it.
creditFundraiserCommission() needs an order's fundraiserId; participantId is
optional. A supporter who bought from the campaign page rather than through one
seller's link still earns the group its share — the participant rollups are simply
skipped. reverseFundraiserCommission() mirrors this, so a credit taken without a
participant can be given back without one.
The thank-you page
A campaign sale confirms on the group's own thank-you page rather than on the
generic store confirmation. Fundraiser.thankYouHeadline, thankYouMessage,
thankYouImageUrl, thankYouCtaLabel and thankYouCtaUrl are all optional;
lib/fundraising/thank-you.ts fills every blank with copy built from the
organisation's own name, so a campaign that customises nothing still gets a page of
its own. An emptied field means "go back to the standard wording", and is stored as
null.
Coordinators edit it themselves under Settings → Thank-You Page in the portal.
Lifecycle emails
lib/fundraising/lifecycle.ts runs from /api/cron/fundraiser-lifecycle (daily,
13:00 UTC) and handles the two moments nobody was previously told about:
- Launch — the coordinator is told their campaign is live.
- Closing summary — the coordinator gets the totals, which is the one thing they need in order to hand money to a school.
This is a sweep rather than an event consumer because a campaign ending has no
actor: it is simply a date passing. No route runs and no event would ever be
emitted. launchEmailSentAt and summaryEmailSentAt are sent-markers — status can
move back and forth while an admin edits, and the coordinator should not be
re-announced each time.
A 30-day window guards the summary. vercel-build wraps prisma migrate deploy in
a warning rather than a failure, so a deploy where the backfill did not apply would
otherwise mail a closing summary to every coordinator whose campaign ever
finished. A window cannot be silently skipped; a data backfill can.
The fundraiser portal
Coordinators sign in to /fundraiser-portal:
| Page | Purpose |
|---|---|
dashboard | Totals, recent orders, progress against goal |
team | Participants, referral codes, per-seller totals |
page-editor | Edit the public campaign page |
advanced-profile | Mission statement, bio, branding |
assets | Logos and cover photos |
analytics | Traffic and conversion for the campaign |
character | The arena avatar (see below) |
settings | Contact details, subdomain, and the thank-you page |
pending | Holding page before access is approved |
Access is governed by FundraiserAccount, FundraiserProfile and
FundraiserAccess, checked through lib/fundraiser-auth.ts — a separate boundary
from admin RBAC. The FUNDRAISER role carries only four permissions:
fundraiser:view-dashboard, fundraiser:edit-page, fundraiser:upload-assets,
fundraiser:view-analytics.
Signup
/fundraise/signup is the public application form. Submissions land as
FundraiserSignup records for an admin to review in
/admin/fundraisers, which is also where campaigns are created and edited.
Gamification and the arena
A campaign can be entered into the Battle Arena, which gives it a FundraiserTeam —
characters, HP, shields, damage. The team is the gamification layer; the campaign owns the
store, so FundraiserTeam.fundraiserId is required and unique and a team sells its
campaign's catalogue at its campaign's price and split. Buying from a team's shop is an
ordinary order that also deals arena damage. See Battle Arena.
Campaigns can opt into social features (enableSocialFeatures). Sales become
FundraiserSaleEvents that participants can react and reply to, with shields,
share events and championships layered on top. That is documented separately in
Battle Arena.
Related
- Battle Arena
- Fundraiser Contacts — the CRM built from the archive
- Bookkeeping Ledger — where commission lands in the books
How is this guide?
Last updated on
Fundraiser Contact Database
The deduplicated database of past fundraising organizations recovered from the document archive, with editable records, an on/off switch, and one-click re-signup outreach
Gift Certificates
Gift certificate purchase, redemption, balance checking, themed designs, and CSV import