Skip to content

feat(print-shop): bulk catalog import with tiered (quantity-break) pricing - #34

Merged
markusthiel merged 7 commits into
markusthiel:mainfrom
manuzzi-photo:feat/print-catalog-import
Sep 16, 2026
Merged

markusthiel merged 7 commits into
markusthiel:mainfrom
manuzzi-photo:feat/print-catalog-import

Conversation

@manuzzi

@manuzzi manuzzi commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Closes #33

Summary

  • 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 products/variants instead of duplicating them; rows without a SKU always create new ones.
  • Import is best-effort: a broken row (missing dimensions, invalid price, bad ladder, unknown category, duplicate SKU within the file) is skipped with an error in the report; the rest of the file still imports. Preview (dryRun: true) and commit share one analysis function, so what a studio previews is exactly what gets written.
  • Added real quantity-tiered pricing, since real-world print price lists almost universally price by quantity break (e.g. 1–19 copies at one rate, down to 400+ at a much lower one) — the catalog previously only supported a single flat price per variant.
    • New PrintProductVariantPriceTier model, purely additive migration, no backfill.
    • Shared ladder validation/resolution (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.
    • Checkout pricing engine (priceCart) now resolves the correct per-unit price from the ordered quantity.
    • Studio variant editor gets a flat/tiered pricing mode with a tier-row editor.
    • Gallery ordering UI (product picker + cart) is tier-aware, live, before checkout; the server remains authoritative for the actual charge.
    • Customer-facing quantity cap raised from 99 to 999 so deep tiers are reachable.
  • i18n: EN/DE/IT/FI, all new strings added in lockstep.

Test plan

  • vitest run in apps/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 --noEmit clean in both apps/api and apps/frontend.
  • npm run check:i18n — all 4 locale dictionaries in sync, no dangling t() keys.
  • Applied the new migration against a fresh Postgres alongside all 78 prior migrations (prisma migrate deploy); confirmed zero schema drift against schema.prisma afterwards.
  • Booted the real API server against that database and exercised the actual HTTP endpoints end-to-end: downloaded the template, previewed and committed an import with a mix of valid/tiered/broken rows (confirmed best-effort skip + correct warnings), re-imported with a matching SKU (confirmed update-not-duplicate), and exercised the manual variant CRUD tiered-pricing edge cases (missing priceCents on flat-switch, invalid ladder, valid switch-back) — all behaved as designed, verified against the DB directly.
  • Called 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.
  • Full browser walkthrough of the Studio import UI and the gallery checkout UI was not performed (would require a seeded auth session + S3/minio-backed file upload beyond this session's scope) — the underlying HTTP/DB behavior they call into was verified directly as above.

🤖 Generated with Claude Code

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>
Copilot AI lite review requested due to automatic review settings September 7, 2026 15:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment on lines +594 to +608
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(),
});
Comment on lines +616 to +619
const importBodySchema = z.object({
providerKey: z.string().min(1),
products: z.array(importProductSchema).min(1).max(1000),
});
Comment on lines +222 to +240
} 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;
}
Comment on lines +497 to +499
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>
@manuzzi

manuzzi commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all 4 review comments in 5f323b6:

  • Import Zod schemas (widthMm/heightMm, tier minQty/maxQty/unitPriceEur) were re-validating ranges that catalog-import.ts already handles per-row — a single out-of-range value anywhere in the file threw a raw ZodError, which the global error handler turns into a 500 for the entire request (verified: no .validation/.statusCode on a bare ZodError, so it doesn't hit the 400 branch). Loosened to type-only checks; range/ladder validation now exclusively lives in the per-row path.
  • Dropped products.min(1) — an empty file now returns a clean zero-row report instead of failing the same way.
  • planVariant() now validates negative costEur (invalid_cost_eur, row skipped), matching how priceEur was already handled and how the manual variant schema treats costCents.
  • Studio tier-row editor now caps at 20 rows client-side (matching the server's MAX_TIERS) and disables "+ Tier" at the limit — also covers the suppressed comment.

Re-verified end-to-end against a real Postgres + live server: a request with a negative width, a negative tier price, and minQty: 0 in the same file now returns 200 with the good row created and each bad row individually reported and skipped (previously 500'd the whole request). Full suite: 231/231 tests passing, tsc clean.

manuzzi and others added 3 commits September 7, 2026 21:08
…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>
@markusthiel

Copy link
Copy Markdown
Owner

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:

PR shown actually its own
#40 bulk select +3160 / 24 files +188 / 5
#42 finish options +3754 / 26 +795 / 19
#44 order exports +3527 / 24 +560 / 9

All six target main and report mergeable, which is the trap: merging #42 first would pull the catalog import in silently and leave this PR a no-op.

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. apps/api/src/services/print/orders.test.ts doesn't exist on main, and #34, #36 and #38 each create it from scratch — 113, 37 and 63 lines. That's an add/add conflict, which is why almost every pair collides there. Whoever lands first creates the file; the other two need to append their cases. Separately, #40 and #42 both touch print-shop/page.tsx and conflict there.

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. 20260907100000_print_variant_price_tiers shows up in three PRs but it's byte-identical — just this branch travelling along.

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.

@markusthiel

Copy link
Copy Markdown
Owner

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:

  • The server never trusts the client. priceCart() loads variants and their tiers from the DB and recomputes; lib/print-pricing.ts is preview-only and says so at the top. Right split, and worth the duplication.
  • resolveCartItemPricing is pure and DB-free, with the Prisma I/O left in priceCart(). That's why the tier math is directly testable, and it shows in the test count.
  • Per-format aggregation is correct and non-obvious — 15 photos each as one 10×15 is 15 units of that variant, not 15 lines that each see quantity 1. The comment explaining it is doing real work.
  • eurToCents flags precision loss instead of swallowing it, and the warning surfaces at all three levels (tier, price, cost).
  • The ladder is validated as a unit, not tier by tier, and a price that rises with quantity is a warning rather than an error. Both are the right calls — a step-up is almost always a typo, but it still resolves coherently, so blocking would be wrong.
  • Per-product transactions, documented as deliberate. Good: a 600-row list failing halfway leaves whole products, not half a product.

Ran the gates against the branch here: 244 tests pass (28 files), check:i18n clean at 2635 keys in all four dictionaries, tsc --noEmit clean in both apps.

Two things, neither blocking.

Is the cost side tiered in the source list? priceTiers carries unitPriceEur, but costEur is a single flat value per variant. If a lab gives quantity breaks on what it charges us as well, then a 400-print order shows a deep customer price against a flat unit cost, and the margin the studio sees is wrong. You designed this against a real reference list — does it have volume breaks on cost, or is cost genuinely flat?

Removing a photo silently raises the price of the others. Falls out of the per-format tier, and the cart handles it correctly — quantityByVariant recomputes every line. But a customer who deletes one photo and watches five other lines get more expensive has no way to know why. There's a tier hint on the picker; is there anything on the cart lines? A short note when a line is priced above its single-unit price would cover it.

@manuzzi

manuzzi commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

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>
@manuzzi

manuzzi commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 7c86f46 addressing both non-blocking points from the review:

Cost per quantity-break tier. unitCostCents now rides along the existing price tier row (same minQty/maxQty) instead of a separate cost ladder — matches how the PhotoSì-style reference list actually shapes a row (one breakpoint, both what we charge and what it costs us). All-or-nothing per ladder (a partial set is rejected as a row/request error, same treatment as the rest of the ladder validation); Studio-only — never priced into checkout, and confirmed not exposed on the public gallery catalog endpoint, which already selects only minQty/maxQty/unitPriceCents. Wired through the manual variant editor (new "cost also varies by quantity" toggle, off by default so existing tiered variants are unaffected), the bulk importer (priceTiers[].costEur, documented in the downloadable template with a worked example), and the product list badge.

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 pricing-tiers.test.ts / catalog-import.test.ts), tsc --noEmit clean in both apps, check:i18n clean at 2641 keys across all four dictionaries.

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 willDowngradeTier only has API-adjacent coverage by analogy, not its own unit test.

@manuzzi

manuzzi commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Frontend now tested on my personal front-end, downgrade alert fully working

@markusthiel
markusthiel merged commit 0d61cb6 into markusthiel:main Sep 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(print-shop): bulk catalog import with tiered (quantity-break) pricing

3 participants