Skip to main content
cases

Green Leaf Printing House — E-Commerce Platform

Production MERN stack e-commerce platform for a Canadian printing company. Express.js v5 backend with 44 MongoDB models, Stripe checkout, a 6-tier loyalty program, and a full React 18 frontend.

View Live
44MongoDB Models
65+API Endpoints
6Loyalty Tiers
27Route Files
MongoDBExpress.js v5React 18Node.jsStripeRedux ToolkitViteTipTapRecharts

The Problem

Green Leaf Printing House is a Canadian printing company — business cards, signage, apparel, custom print runs. Their existing setup was a generic e-commerce template. The problem was pricing.

Print jobs are priced by quantity, material, finish, and turnaround time. A 500-unit business card order in coated stock at standard turnaround has a different unit price than the same job at 250 units, uncoated, rush. SKU-based e-commerce assumes one product has one price. It doesn't model this. The template they had required either a separate SKU per combination — which meant hundreds of SKUs for a single product — or a fallback to a custom quote flow for anything non-standard, which was most orders.

They also needed a loyalty program with escalating discounts for repeat business customers, and a promotional popup system marketing staff could control from a dashboard without involving a developer.

Shopify with custom metafields was the obvious starting point. I scoped it seriously. The pricing matrix alone would have required fighting Shopify's product model in ways that got progressively messier the further I went — metafields can store the data, but the cart calculation logic doesn't have access to them cleanly at checkout without a custom app. The loyalty program and popup system had the same issue: technically possible, but each one required bending Shopify's model in a different direction, and the sum of those bends was more work than building the platform directly. Building on the MERN stack meant building more infrastructure. It was the right trade.

Backend Architecture

The backend is Express.js v5 — 27 route files, 38 controllers, 44 MongoDB models.

44 models requires justification because it sounds like it could be schema proliferation. It isn't. It's what the domain actually contains: products with pricing matrices that have quantity breaks per material and finish combination, orders with a full lifecycle state machine (pending → confirmed → in-production → shipped → delivered), customers with embedded loyalty state and communication preferences, a media library with year/month directory organisation, popup campaigns with per-variant analytics, coupons with per-customer usage limits. Each of those is several models. The count is high because the business logic is genuinely complex, not because the schemas are loose.

Index design was not optional at this count. Compound indexes on the orders collection handle the most common admin queries — by customer, by status, by date range — without collection scans. The difference between having the right index and not having it on the orders collection was the difference between an 8ms query and an 800ms one. I measured both.

The Webhook Problem

The first version of the webhook handler processed Stripe events as they arrived. No idempotency keys, no duplicate detection. During staging, I deliberately delayed webhook processing to test timeout behavior — long enough that Stripe retried the event. The order got processed twice. The loyalty recalculation ran twice. A customer's tier changed based on spend that hadn't actually doubled.

This was easy to reproduce in staging and would have been nearly impossible to trace in production where retries are rare and the data corruption would look like a data entry error.

The fix uses Stripe's event IDs as idempotency keys. Each event ID is written to a processedEvents collection before processing. If the write fails because the ID already exists, the handler returns 200 and does nothing — Stripe considers the webhook delivered, the duplicate is silently discarded. The second piece is the state machine: orders only allow forward transitions. A payment.succeeded event arriving after charge.refunded is a no-op, not a corruption. The state machine doesn't care about event ordering; it cares about whether a given transition is valid from the current state.

Loyalty recalculation triggers on confirmed payment. Which meant the loyalty program needed its own protection against inconsistent state.

Tiered Loyalty Program

Six tiers — Bronze through Elite — with spend-based automatic calculation. Tier thresholds are configurable from the admin dashboard without a deployment, which matters because the client wanted to adjust them seasonally.

The constraint that made this harder than it looks: a refund must subtract from lifetime spend and potentially demote a tier. A dispute must hold the tier change in a pending state until resolved. Loyalty state and spend totals can never be out of sync — a customer at Gold tier with Bronze-level spend is a data integrity problem, not just an inconsistency.

I considered an eventual-consistency approach: update spend, then queue a recalculation job. The failure mode is a window where the spend total and the tier don't agree. That window is usually milliseconds, but it's real, and the consequences of a customer seeing a demoted tier before the recalculation runs are bad enough that I didn't want the window to exist at all. I used MongoDB transactions — the spend update and the tier update happen together or neither happens. Slightly slower. Worth it for the guarantee.

Frontend

React 18 + Vite with TypeScript. Vite's manualChunks config splits vendor bundles aggressively — React and React DOM in one chunk, Recharts in another, TipTap in a third. Initial bundle is under 120KB gzipped. The customer base skews toward mobile, which made bundle size a real constraint rather than a theoretical one.

Axios interceptors handle token refresh transparently — a 401 triggers a refresh and retries the original request before surfacing any error. Users with expired sessions get silently re-authenticated on the next request.

TipTap powers the product description editor in the admin dashboard. I needed three custom extensions: image upload integrated with the media library API, table support, and a "print spec" block that renders a structured spec table on product pages. The TipTap extension API is capable. The documentation for custom node types is thin enough that I spent significantly more time on those three extensions than the rest of the frontend editor work combined.

What Shipped

The platform replaced the previous template and launched to production. The custom pricing matrix handles all product types. The loyalty program is live across six tiers. Marketing manages popups from the dashboard.

What I'd Change

The relationship between Products, ProductVariants, and PricingMatrices is the part of the schema I'm least satisfied with. The pricing matrix evolved as I discovered edge cases — a product with per-finish quantity breaks that are different from per-material quantity breaks, which then needed an override at the individual variant level for promotional pricing. Each discovery meant extending the schema in a direction that made sense locally but added complexity to the overall shape.

I know the current schema is tangled. I don't have a clean version of it in my head yet. I've thought about normalising it differently, flattening some of the nesting, pulling the pricing logic into a separate calculation service that takes product + configuration and returns a price. None of those ideas feel clearly better — they move the complexity rather than eliminating it. The problem might be that print pricing is genuinely complex and any schema that models it faithfully will have rough edges. Or the problem might be that I made early decisions that constrained the shape in ways I haven't fully reasoned through. I'm not sure which.

The TipTap estimation miss was simpler: I budgeted time for "add TipTap" and not for "build three custom TipTap extensions from scratch." Those are different tasks with different timelines. Any rich text requirement that involves custom node types needs a research spike before the estimate.