A few times a year, Fullscript runs a sale. That sentence hides a lot of engineering. A promotion at our scale touches the pricing engine, the product catalog, search, several frontend surfaces, and more, plus one hard rule: promotional pricing is locked before the sale begins.

Structurally, we run two kinds of promotions. Store promotions are storewide, order-level sales: practitioners opt in, and every product in their store gets an extra discount for a few days. Brand promotions are item-level: a curated set of SKUs goes on sale, and the rest of the catalog doesn't. The two look similar on a banner, but they stress completely different parts of the platform.

Over a year of running both, they converged on one engineering insight for both kinds: a promotion should be a record in a database, not code in a release. This post covers how we got there, what broke along the way, and the pattern we'd recommend to anyone shipping time-boxed campaigns.

The challenge

Everything was code. For the first promos, launching meant new frontend components, statically imported banner images, a deployment timed to the sale, and a teardown PR afterwards. Practitioner notifications were the same story: each campaign needed its own bespoke service, its own rake task, and its own copy of the eligibility logic. Tolerable once. Not tolerable four times a year, across five surfaces, two audiences, and two viewports.

Item-level discounts broke our assumptions. Storewide promotions are order-level, so the catalog barely needs to know a sale exists. Item-level discounts apply to an arbitrary set of SKUs, which means every product card, product page, cart, and checkout has to know exactly which products are discounted, by how much, and until when. And the inverse question ("which products are on sale right now?") is prohibitively expensive if you answer it by scanning the catalog against every promotion rule on each request.

Price integrity is a hard requirement. Once a sale is announced, the prices patients see need to stay exactly as promised for the full promotional window. That means discount configuration freezes before the sale starts and stays frozen until it ends. It's a guarantee the platform enforces, not a convention.

The solution, part one: store promotions

An opt-in cycle with a freeze

Each store promotion is a promotion in our commerce engine, wrapped in a four-phase operational cycle:

  1. Opt-in. Targeted hub banners and notifications drive enrollment, all instrumented so conversion can be measured.
  2. Freeze. At cutoff, opt-ins and discount changes lock platform-wide. We verify the lock rather than assume it.
  3. Live. The promotion and every patient-facing banner activate at midnight in the eastern timezone.
  4. Teardown. Discounts deactivate, banners come down, and configuration unfreezes, all verified the same way the freeze was.

Everything is staged behind feature flags, so opt-in surfaces ship weeks before patient surfaces. After the first hardcoded-banner sale, we moved banners into a promotion record: images stored as attachments served from a CDN, exposed through the API, consumed by frontend hooks. A new sale's banners became a data change instead of a frontend project. Keep that move in mind; it comes back bigger in part two.

Notifications as configuration

The opt-in phase is where the "data, not code" idea first proved itself beyond banners. Promo notifications have genuinely fiddly targeting: some campaigns go to store owners only, some to every practitioner, some to clerks too; some target enrolled stores, some target the unenrolled (reminders), and some need union logic across both enrollment types. Early on, each variation was a bespoke service with its own duplicated eligibility checks.

We replaced all of that with a config-driven notification system. A campaign is a single configuration entry: copy, call-to-action, expiry, recipient rules, eligibility rules. One shared pipeline does the rest: an operator-triggered task filters eligible stores, excludes anyone already notified, and fans out to batched background jobs that run the shared eligibility checks and create the notifications. The pipeline is idempotent (re-running never double-notifies), monitored end to end, and comfortably handles tens of thousands of notifications per campaign. A new campaign went from roughly 500 lines of bespoke code to about 25 lines of configuration, and the same system now serves any practitioner-facing announcement, not just promotions.

The solution, part two: brand promotions

Brand promotions are the item-level model, and they needed two things store promotions never did: per-product pricing across the whole catalog, and a way to launch a much richer set of banners (per promotion, per brand, per audience, per viewport) without turning every sale into a frontend project.

Item-level pricing, end to end

Making the catalog accurate about item-level discounts took three layers of work.

Pricing. The catalog can answer "which active item-level promotions apply to this product?" by evaluating active promotions and their rules, returning only stackable ones so it never advertises a discount that can't combine with what the shopper already has. The pricing layer folds those discounts into the price actually shown, and the API exposes the promotion details (percentage and end date) so product cards, product pages, cart, checkout, and auto-refill all render discount chips and "sale ends" context from one source of truth.

The on-sale set, derived at write time. The On Sale page, catalog filter, and menus need the inverse query: which products are on sale right now. Instead of scanning the catalog, a service walks active promotions and their rules to derive the affected product set. A background job runs it on every promotion lifecycle event (activate, deactivate, rule change) and writes the result to a shared cache. The search pipeline consumes that cache when building the catalog index, stamping each product with an on-sale flag and an expiry timestamp. The on-sale filter becomes a plain search query, and the expiry guarantees nothing shows as on-sale after the promotion lapses.

The tradeoff: keeping the index accurate means reindexing on every promotion change, including rule edits. We took that write-time cost deliberately. It's cheap and bounded, where the read-time alternative was neither.

A brand promotion is a record

The first brand promos still shipped with hardcoded banners: brand names in component code, a deploy to launch, another to tear down. That was the second time we paid the hardcoded-banner tax, and it's where we designed the end state, taking the store-promotion banner record from part one and making the whole promotion the record: a brand promotion is a database record, and everything else derives from it.

The model is simple. A brand promotion record holds the name, the date window, and the promotion-level banners stored as attachments, with variants per audience and per viewport. Linked per-brand records carry the tiles shown when a shopper filters the catalog to a participating brand. Images live in object storage behind the CDN, URLs resolve at runtime, and nothing is bundled into the frontend.

Activation is a date query, not a deploy. The API exposes a single "active brand promotion" query that resolves to whichever promotion's date window covers now, kept cheap by an indexed date lookup. Frontend hooks render whatever the API returns, and render nothing when it returns nothing. That one choice gives us automatic activation and deactivation: at midnight on the start date every banner on every surface appears, and at the end date they all disappear. No launch-night deploy, no teardown PR, no one setting an alarm to flip a flag.

Launching is a single step. Every asset for the promotion, across all surfaces, audiences, viewports, and brand tiles, ships together in one reviewed, revertible data change that creates the record and attaches the banners. The entire promotion can be staged weeks in advance.

It scales. Promotions resolve by date, so two can overlap without code knowing about either. Brand tiles are fetched on demand, through a lightweight query that fires only when a shopper filters to exactly one participating brand, so a promotion with twelve brands costs the initial page load nothing.

Notably, none of this touched the discount calculation engine. The engine was never the bottleneck; the presentation layer was.

Results

Launch cost collapsed. Standing up a promotion used to mean a multi-component frontend project, a deploy timed to the sale, and a teardown PR. Today it's a folder of images and one reviewed data change, and takedown is a non-event because the date window handles it. Notifications went from ~500 lines of bespoke code per campaign to ~25 lines of configuration.

Promos compound instead of restarting. Every sale over the past year ran on the same machinery, and each one reused what the last one built. The brand promos added exactly one new layer each time: first the pricing mechanics, then A/B-tested curation, then native app support. That compounding is only possible because the layer underneath each promo is data, not code.

The failure modes are gone. No more midnight deployments, no forgotten teardowns, no stale banners after a sale ends, no hand-written notification services drifting out of sync with eligibility rules.

How any team can implement this

The pattern transfers to any platform running recurring campaigns or time-boxed content:

  1. Model the campaign as a record with a date window. Name, start date, end date, assets attached to the record, children for per-entity variations. Resolve "what's active" at read time with an indexed date query.
  2. Make time a property of the data, not the deploy. If activation requires a release or a manual flag flip, someone owns a midnight task and a teardown chore forever.
  3. Invert expensive queries at write time. "What's on sale" is brutal as a read-time question and nearly free at write time. Derive the set when campaigns change, cache it, and push it into your search index with an expiry field so stale data self-heals.
  4. Turn repeated campaign code into configuration. The second time you write a campaign-specific service, stop. Extract the shared pipeline (eligibility, batching, dedupe, monitoring) and make each campaign a config entry. Idempotency is what makes operators trust it.
  5. Don't rebuild the engine; change what feeds it. Our discount calculation never changed. Identify which layer actually needs work (for us: derived data and presentation) and leave the rest alone.
  6. Treat price integrity as a platform feature. Freezes, cutoffs, and expiry windows keep promotional pricing consistent and predictable. Build and verify them with the same rigor as the pricing math.

Pitfalls: Three mistakes to avoid. Don't hardcode campaign content "just this once"; we did, twice, and regretted it both times. Don't forget that every rule change needs to invalidate your search index, not just campaign activation. And don't fetch every campaign asset up front, or the cost of a campaign will grow with its size.

Conclusion

The core insight from a year of promotions is simple: the closer a promotion gets to being pure data, the cheaper it is to run and the harder it is to get wrong. Every step in that direction removed a failure mode and a human from the launch path: attachments over static imports, date windows over deploys, configuration over bespoke services, write-time derivation over read-time scans.

And once a promotion is just a record, the launch path can keep shrinking, because it no longer matters what creates that record.