feat(print-shop): bulk catalog import with tiered (quantity-break) pricing - #34
Conversation
Studios can now bulk-create/update their print catalog from a downloadable JSON template instead of adding every product/variant by hand. Import is upsert-by-SKU (re-uploading an updated file updates matching rows instead of duplicating them) and best-effort: a broken row is skipped with an error in the report, the rest of the file still imports. Preview and commit share one analysis function, so what a studio previews is exactly what gets written. Real-world print price lists almost universally price by quantity break (e.g. 1-19 copies at one rate, 400+ at a much lower one), which the catalog previously couldn't represent — PrintProductVariant only had a single flat price. Added a proper tiered-pricing model: PrintProductVariantPriceTier (additive migration, no backfill), a shared ladder-validation/resolution module used identically by manual variant editing and the importer, and wired the checkout pricing engine (priceCart) to resolve the correct per-unit price from the ordered quantity. The gallery ordering UI (product picker + cart) and the Studio variant editor are now tier-aware; the customer-facing quantity cap moved from 99 to 999 so deep tiers are reachable. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The import endpoint validation currently blocks the “best-effort per-row” behavior and the importer allows negative costEur, both of which can lead to incorrect/failed imports.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR adds quantity-tiered (quantity-break) pricing for print variants and introduces a best-effort bulk JSON import flow to upsert print catalog products/variants by SKU, with UI updates to preview tier-aware pricing client-side.
Changes:
- Added server-side tier ladder validation + unit-price resolution and integrated it into checkout pricing (
priceCart) and catalog import. - Implemented bulk catalog import endpoints (template download, preview, commit) and a Studio UI flow to upload/preview/commit imports.
- Updated Studio + gallery UIs and i18n to display and edit tiered pricing, and raised the public quantity cap to 999.
File summaries
| File | Description |
|---|---|
| apps/frontend/src/lib/print-pricing.ts | Client-side unit price resolution for tiered pricing previews. |
| apps/frontend/src/lib/i18n/it.ts | Adds tiered pricing + import UI strings (IT). |
| apps/frontend/src/lib/i18n/fi.ts | Adds tiered pricing + import UI strings (FI). |
| apps/frontend/src/lib/i18n/en.ts | Adds tiered pricing + import UI strings (EN). |
| apps/frontend/src/lib/i18n/de.ts | Adds tiered pricing + import UI strings (DE). |
| apps/frontend/src/lib/api.ts | Extends API types for priceTiers and adds import API helpers/types. |
| apps/frontend/src/app/studio/print-shop/products/page.tsx | Adds tiered pricing editor + tier ladder display and links to import UI. |
| apps/frontend/src/app/studio/print-shop/import/page.tsx | New Studio page for template download, import preview, and commit. |
| apps/frontend/src/app/g/[slug]/print-shop/page.tsx | Tier-aware pricing display + subtotal computation; raises qty cap to 999. |
| apps/api/src/services/print/pricing-tiers.ts | New tier ladder validation + unit price resolution utilities. |
| apps/api/src/services/print/pricing-tiers.test.ts | Unit tests for ladder validation and unit-price resolution. |
| apps/api/src/services/print/orders.ts | Uses tier resolution in priceCart() and loads tiers from DB. |
| apps/api/src/services/print/catalog-import.ts | New best-effort import planner/writer and template builder. |
| apps/api/src/services/print/catalog-import.test.ts | Unit tests for import planning and EUR→cents conversion behavior. |
| apps/api/src/routes/print-shop.ts | Adds tier-aware variant CRUD + new import endpoints and schemas. |
| apps/api/src/routes/print-shop-public.ts | Exposes price tiers to public catalog + raises cart quantity cap to 999. |
| apps/api/prisma/schema.prisma | Adds PrintProductVariantPriceTier model and relation from variants. |
| apps/api/prisma/migrations/20260907100000_print_variant_price_tiers/migration.sql | Additive migration creating the tier table + indexes/constraints. |
Review details
Suppressed comments (1)
apps/frontend/src/app/studio/print-shop/products/page.tsx:698
- If tier rows are capped at 20, the "+ Tier" button should also be disabled at that limit to avoid a confusing no-op click.
<Button type="button" variant="secondary" size="sm" onClick={addTierRow}>
{t("printProducts.addTier")}
</Button>
- Files reviewed: 18/18 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const importTierSchema = z.object({ | ||
| minQty: z.number().int().min(1), | ||
| maxQty: z.number().int().min(1).nullable(), | ||
| unitPriceEur: z.number().min(0), | ||
| }); | ||
| const importVariantSchema = z.object({ | ||
| name: z.string(), | ||
| widthMm: z.number().positive().nullable().optional(), | ||
| heightMm: z.number().positive().nullable().optional(), | ||
| finishType: z.string().nullable().optional(), | ||
| sku: z.string().nullable().optional(), | ||
| priceEur: z.number().nullable().optional(), | ||
| costEur: z.number().nullable().optional(), | ||
| priceTiers: z.array(importTierSchema).max(20).optional(), | ||
| }); |
| const importBodySchema = z.object({ | ||
| providerKey: z.string().min(1), | ||
| products: z.array(importProductSchema).min(1).max(1000), | ||
| }); |
| } else if (row.priceEur === undefined || row.priceEur === null) { | ||
| errors.push("missing_price_eur"); | ||
| } else { | ||
| const { cents, imprecise } = eurToCents(row.priceEur); | ||
| if (imprecise) warnings.push("price_rounded_to_nearest_cent"); | ||
| if (cents < 0) errors.push("invalid_price_eur"); | ||
| priceCents = cents; | ||
| } | ||
|
|
||
| if (errors.length > 0) { | ||
| return { rowIndex, sourceName: name, action: "skip", errors, warnings }; | ||
| } | ||
|
|
||
| let costCents: number | null = null; | ||
| if (row.costEur !== undefined && row.costEur !== null) { | ||
| const { cents, imprecise } = eurToCents(row.costEur); | ||
| if (imprecise) warnings.push("cost_rounded_to_nearest_cent"); | ||
| costCents = cents; | ||
| } |
| function addTierRow() { | ||
| setTierRows([...tierRows, { minQty: "", maxQty: "", priceEuros: "" }]); | ||
| } |
…one bad row The bulk import's Zod schemas were re-validating value RANGES (.positive(), .min()) that catalog-import.ts already handles per-row — so a single out-of-range value anywhere in the file (a zero width, a negative tier price, minQty 0) threw a raw ZodError, which the global error handler turns into a 500 for the ENTIRE request, silently defeating the "best-effort, skip only the broken row" design this importer exists for. Loosened the import schemas to type-only checks and let the existing per-row validation in catalog-import.ts do its job; also dropped products.min(1) so an empty file returns a clean zero-row report instead of failing the same way. Also: planVariant() validated negative priceEur but not negative costEur (inconsistent with the manual variant schema, which rejects it) — added the same check. And the Studio tier-row editor had no client-side cap matching the server's 20-tier limit, so a studio could fill in 21+ rows before hitting a submit-time rejection — capped it client-side too. Verified against a real Postgres + live API server: a request that previously 500'd on one bad row (negative width, negative tier price, minQty 0) now returns 200 with the good row created and each bad row individually reported and skipped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Addressed all 4 review comments in 5f323b6:
Re-verified end-to-end against a real Postgres + live server: a request with a negative width, a negative tier price, and |
…elper Provider names (e.g. manual_self_print's "Selbst drucken") come from the API in German and are meant to be localized client-side through useCatalogText()/ct() — the mechanism and dictionary entries already existed (catalogProviderManualSelfPrintLabel etc. in en/it), but the label itself was rendered raw in six places across the print-shop admin UI (providers list x2, product provider select, product list caption, shipping provider select/caption, and the new import page's provider select), while the tagline right next to it already went through ct(). Wired all six up; added the one missing dictionary key (printProducts.providerPrefix) needed by one of them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…le cart resolveUnitPriceForQuantity() was called with each cart line's own quantity, so a customer ordering 15 different photos as one 10x15 print each got priced as fifteen separate 1-copy lines (all landing in the cheapest-quantity tier) instead of one 15-print order of that format. Tiered pricing is meant to reward how many prints of a given FORMAT are ordered in total, not how many copies of one specific photo — a customer printing 15 different photos at 10x15 has ordered 15 units of "10x15", same as if they'd ordered 15 copies of one photo. Fixed by summing quantity per variantId across the whole cart first, then resolving each line's per-unit price from that aggregate — each line's own quantity still only determines its own line total. Extracted the pricing math out of priceCart() into a pure resolveCartItemPricing() so it's directly unit-tested without touching Prisma, matching this codebase's established testing convention. Also fixed the client-side live preview (product picker + cart) in the gallery ordering UI to aggregate the same way, so what a customer sees before checkout matches what priceCart() will actually charge them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Order detail only showed the filename to print, with no way to get the actual file or a structured summary for invoicing. Two additions: - Each order item's filename now links to the existing, already access-controlled GET /files/:id/download endpoint — no new backend download logic needed, just wiring the studio order-detail UI to it. - New GET /print-shop/orders/:id/export.csv: one row per order item (file ID, filename, product, format, dimensions, SKU, quantity, unit price, line total), so a studio doesn't have to retype order details by hand into their invoicing tool. SKU falls back from the variant's own reference to the parent product's when the variant has none. CSV building is a pure, unit-tested function (order-export.ts), mirroring the existing proofing-export CSV pattern (BOM + quoted cells for Excel/Numbers/LibreOffice). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Precompute per-variant cart quantities once (buildQuantityByVariantMap) instead of re-aggregating the whole cart inside cart.map() for every line — was O(n^2), flagged as a real render-time cost on PR markusthiel#40/markusthiel#44 and made more likely by the new bulk-select flow (bigger carts). - Collapse three redundant aggregateQuantityForVariant() calls in the picker's per-unit/subtotal preview into one computed value. - Tier ladder quantities (Studio product editor) used parseInt(), which silently truncates non-integer input ("1.5" -> 1) instead of rejecting it, even though the server requires integers. Now uses a strict Number.isInteger() check so a typo doesn't silently submit an unintended ladder. Flagged by Copilot on PR markusthiel#40, markusthiel#42, markusthiel#44 (this code is shared across all three via the branch they're stacked on). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Six PRs in two days is a lot of ground covered — thanks. Before reviewing the features themselves I went through how they fit together, because the stack isn't visible from the outside and I'd have merged them in the wrong order. #40, #42 and #44 all have this branch as an ancestor. They carry its six commits, so GitHub is showing their combined size rather than their own:
All six target Could you retarget #40, #42 and #44 onto this branch? Then the diffs show what each one actually adds, and the dependency is visible instead of inferred. Three conflicts that no merge order avoids. I'd rather you rebased those than have me resolve them at merge time — you know which test cases belong together, and I'd be guessing in a conflict editor. Migrations are clean, for the record: four of them, ascending timestamps, each on its own concern, none touching the same table. Proposed order once the retargeting is done: this one, then #40, #44, #42, #36, #38. Feature reviews to follow — I didn't want to comment on six PRs while the shape underneath them was still in question. |
|
Read this one properly rather than skimming, since it's the base of the stack and it touches money. It holds up well. What I checked specifically, because these are the places pricing code usually goes wrong:
Ran the gates against the branch here: 244 tests pass (28 files), Two things, neither blocking. Is the cost side tiered in the source list? Removing a photo silently raises the price of the others. Falls out of the per-format tier, and the cart handles it correctly — |
|
Thanks @markusthiel for the review and all the comments. About the cost tiers: good catch, the logic is based as one of my Suppliers (PhotoSì) has price tiers based on ordered quantity, here am example, and costs follow that logic too (e.g. cost are roughly 40% discounted from the final product price) About the "hidden" price change, good catch, I'll look forward to introduce an alert in case that change will cause a tier downgrade |
Addresses the two non-blocking points from markusthiel's review: - Cost per quantity-break tier: unitCostCents rides along the existing price tier row (same minQty/maxQty) instead of a separate ladder — matches how a real supplier list (PhotoSì) prices both what we charge and what it costs us at the same breakpoint. All-or-nothing per ladder, Studio-only (never priced into checkout, never exposed on the public gallery catalog). - Cart now warns when removing an item or lowering its quantity pushes the remaining lines of the same format into a worse tier, since that silently raises their price with no visible cause otherwise. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Pushed 7c86f46 addressing both non-blocking points from the review: Cost per quantity-break tier. Silent tier downgrade on removal. The cart now shows a dismissible notice when removing an item — or lowering its quantity — pushes the remaining lines of that format into a worse tier, since that raises their price with no visible cause otherwise. Non-blocking, clears itself if the next cart change doesn't also cause a downgrade. Verified: 253/253 API tests passing (added coverage for the new cost-tier paths in Not verified here: no local Postgres in this environment, so the new migration hasn't been applied/exercised against a live DB, and there's no frontend test runner configured in this repo yet, so |
|
Frontend now tested on my personal front-end, downgrade alert fully working |
Closes #33
Summary
dryRun: true) and commit share one analysis function, so what a studio previews is exactly what gets written.PrintProductVariantPriceTiermodel, purely additive migration, no backfill.services/print/pricing-tiers.ts) used identically by manual variant editing and the importer, so the two paths can never disagree on what a valid ladder is.priceCart) now resolves the correct per-unit price from the ordered quantity.Test plan
vitest runinapps/api— 229/229 tests pass, including 38 new unit tests covering tier-ladder validation, quantity resolution, and best-effort/upsert-by-SKU import logic.tsc --noEmitclean in bothapps/apiandapps/frontend.npm run check:i18n— all 4 locale dictionaries in sync, no danglingt()keys.prisma migrate deploy); confirmed zero schema drift againstschema.prismaafterwards.priceCentson flat-switch, invalid ladder, valid switch-back) — all behaved as designed, verified against the DB directly.priceCart()directly against a real tiered variant across the full quantity range (1, 19, 20, 150, 400, 1000) — resolved prices matched the ladder exactly at every tier boundary.🤖 Generated with Claude Code