Welcome to the Jose Madrid Salsa developer docs — explore features, APIs, and deployment guides.
Jose Madrid SalsaJMS Docs

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.

StatusMeaning
DRAFTBeing set up; not public
ACTIVELive and selling
ENDEDPast its end date
CANCELLEDCalled 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:

MethodWhat it means
ORDER_FORMS_AND_BULKThe 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_ONLYNo 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:

  1. FundraiserProduct.price — this campaign's price for this one product.
  2. Fundraiser.defaultUnitPrice — the campaign's store price. Defaults to 10.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:

PagePurpose
dashboardTotals, recent orders, progress against goal
teamParticipants, referral codes, per-seller totals
page-editorEdit the public campaign page
advanced-profileMission statement, bio, branding
assetsLogos and cover photos
analyticsTraffic and conversion for the campaign
characterThe arena avatar (see below)
settingsContact details, subdomain, and the thank-you page
pendingHolding 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.

How is this guide?

Edit on GitHub

Last updated on

On this page