Variable pricing, artwork and production: how I modeled a print-shop e-commerce without turning everything into exceptions
How I built Digital Tech MS: server-side configurable pricing, quotes, immutable order snapshots, artwork approval and a production flow that treats physical mistakes as business risk.
- ecommerce
- next.js
- supabase
- ddd
- payments
A business card already breaks the idea that product equals price
Traditional e-commerce loves a comfortable model: product, name, price, inventory.
A print shop does not cooperate.
In Digital Tech MS, the same product could vary by dimensions, material, finishing, quantity and other options. Some combinations had calculable prices; others had to become a quote.
There was also a second variable ordinary stores tend to underestimate: artwork.
If a SaaS stores the wrong text, a record can be corrected. If a print shop produces 5,000 units with the wrong artwork, material, machine time and labor are already lost.
That made two concerns central from the beginning: the browser cannot be the pricing authority, and production cannot start without an explicit approval state.
I stopped treating every variation as a column
A tempting first model is to add paper_type, size, finish, quantity and every future option to products.
It works until the next product has completely different options. A banner does not behave like a business card; a custom item may require free dimensions.
Instead, I separated product from configuration:
type ProductOption = {
id: string
name: string
type: 'select' | 'number' | 'boolean'
required: boolean
}
type ProductOptionValue = {
optionId: string
label: string
priceModifier?: number
}
The catalog describes what can be configured. The pricing rule interprets that configuration.
The browser may preview a price; it may not decide the price
Instant frontend feedback is useful, so estimating while the customer changes options is fine. But that number is never authoritative.
browser calculates preview
↓
customer adds to cart
↓
server receives configuration
↓
server loads current rules
↓
server recalculates
↓
trusted value is persisted
A request that contains a client-selected price is effectively letting the customer say how much they want to pay.
I would rather receive a product identifier plus configuration and let the server turn that into money.
A pricing engine should explain the total
Variable pricing is easier to maintain when it returns a breakdown instead of a mysterious number.
type PriceBreakdown = {
base: number
adjustments: Array<{ label: string; amount: number }>
total: number
}
That helps customers understand the price, support explain it and developers debug it. Business logic that only returns 175 is much less observable than logic that says where 175 came from.
Some things should not fit inside the pricing engine
Automation has a cost. At some point a rare custom rule becomes more expensive to encode than to route into a quote workflow.
That is why I like an explicit mode:
type PricingMode = 'fixed' | 'calculated' | 'quote'
A quote is not failed automation. It is a deliberate escape hatch that lets complex products remain in the same customer account and history without creating an unmaintainable rules engine.
An order must be a snapshot, not a pointer to the catalog
If a customer buys 1,000 units with a specific paper and finishing for a given amount, that agreement must remain true after tomorrow's catalog update.
type OrderItemSnapshot = {
productId: string
productName: string
configuration: Record<string, unknown>
unitPrice: number
quantity: number
total: number
}
The catalog is mutable. The order is a historical fact.
Confusing those concepts creates financial bugs that are much easier to produce than they should be.
Artwork approval is a state machine
Artwork could not be a loose checkbox. The domain needs explicit states:
awaiting_artwork
↓
artwork_received
↓
under_review
↙ ↘
changes approved
requested ↓
↑ production
└─────────
Now the system can answer who submitted the current version, which file was approved, when approval happened, whether changes were requested and whether production was authorized at that point.
Approval becomes part of the order history instead of a message lost in chat.
"Approved" must point to a concrete file version
A single artwork_url is not enough when there are revisions.
type ArtworkVersion = {
id: string
orderId: string
storagePath: string
version: number
uploadedAt: Date
status: 'pending' | 'changes_requested' | 'approved'
approvedAt?: Date
approvedBy?: string
}
If the customer uploads a new file after approval, the new version does not inherit approval by accident.
That small bit of traceability matters a lot when software controls an irreversible physical process.
Production is a different bounded context from checkout
This project reinforced my preference for bounded contexts inside a modular monolith when independent deployment is unnecessary.
catalog
pricing
cart
quote
order
payment
artwork
production
shipping
pickup
notifications
Payment should not know how artwork is reviewed. Artwork should not recalculate prices. Cart should not decide whether production can begin.
A monolith does not have to mean mixed responsibilities.
Important architecture rules should be executable
The bigger the codebase, the less I trust "the team knows not to import that".
If a boundary matters, I try to turn it into automated feedback. ESLint import restrictions, for example, can keep domain code from depending directly on infrastructure or framework modules.
Documentation that says "domain does not depend on framework" is useful. A build that fails when someone violates it is better.
Paid does not mean ready for production
An order can be paid and still have unapproved artwork.
const canEnterProduction =
order.paymentStatus === 'paid' &&
order.artworkStatus === 'approved'
The bug appears when one screen checks only payment status and exposes a production action too early.
I prefer centralizing this invariant in the domain so interfaces ask the order whether production is allowed instead of rebuilding the rule.
Store pickup is a transaction too
"The customer picked it up" sounds trivial until the wrong person or wrong order is handed over.
The project uses a redeemable pickup code: ready order, code issued, customer presents it, staff validates it, code is consumed, order is delivered.
A consumed code should not work again. Once again, the right abstraction is an atomic and auditable transition.
End-to-end tests are most valuable where bugs cross boundaries
I do not try to test every pixel with E2E. I focus Playwright on paths where a defect can cross systems and become money or wasted material:
- cart to payment;
- payment to order;
- artwork upload to approval;
- approval to production;
- production to shipping or pickup.
Pure rules can stay in unit tests. Critical cross-boundary workflows deserve a real browser.
What this project changed in how I think about e-commerce
I used to think about commerce primarily from catalog to checkout. Projects like this made me start with what happens after the money arrives.
For custom physical products, the real system is closer to:
configuration
→ price
→ agreement
→ file
→ approval
→ production
→ delivery
Checkout is just one stage.
Once the whole chain is modeled, immutable orders, artwork versioning and production gates stop looking like technical polish. They become what they really are: protection against operational loss.