Skip to content

fix: prevent DB blowups in pricing (USD mirror + product_prices) and enforce PRICE_CONFIG_ERROR, stabilize product pricing validation, multi-currency storage, an…#101

Merged
ViktorSvertoka merged 4 commits into
developfrom
lso/feat/shop
Jan 1, 2026
Merged

fix: prevent DB blowups in pricing (USD mirror + product_prices) and enforce PRICE_CONFIG_ERROR, stabilize product pricing validation, multi-currency storage, an…#101
ViktorSvertoka merged 4 commits into
developfrom
lso/feat/shop

Conversation

@liudmylasovetovs
Copy link
Copy Markdown
Collaborator

@liudmylasovetovs liudmylasovetovs commented Jan 1, 2026

Description

This PR fixes production-blocking issues in shop product pricing and cart rehydrate behavior.

Key outcomes:

  • Restores build correctness in frontend/lib/services/products.ts (TS error: Cannot find name 'p').
  • Hardens admin product create/update to be “transitional-safe” while we migrate to canonical multi-currency pricing:
    • Accepts both new prices[] payloads in minor units (priceMinor, originalPriceMinor) and legacy major string prices (price, originalPrice), normalizing to minor units.
    • Enforces invariants to prevent database inconsistencies (no duplicate currencies; originalPrice > price when present).
    • Maintains a required USD legacy mirror on products.price/originalPrice/currency to satisfy existing constraints and keep backward compatibility, while storing source-of-truth prices in product_prices.
    • Upserts product_prices only for currencies provided in the request.
  • Enforces the cart rehydrate pricing contract:
    • No fallbacks when product_prices is missing for the resolved currency.
    • Returns a controlled 422 PRICE_CONFIG_ERROR with { productId, currency } details.

Related Issue

Issue: #<issue_number>


Changes

  • Fixed pricing validation logic in frontend/lib/services/products.ts to remove invalid p reference and correctly validate per-price rows.
  • Implemented robust normalization + validation for multi-currency admin inputs (minor units canonical; legacy major strings supported).
  • Ensured admin create/update writes product_prices as canonical store and keeps products.* as USD legacy mirror.
  • Updated cart rehydrate to require product_prices for the resolved currency and return PRICE_CONFIG_ERROR when missing (no implicit conversion/fallbacks).

Database Changes (if applicable)

  • Schema migration required
  • Seed data updated
  • Breaking changes to existing queries
  • Transaction-safe migration
  • Migration tested locally on Neon

Notes:

  • No schema changes in this PR.
  • Behavior now assumes product_prices is the canonical price source for cart rehydrate; missing currency config is treated as a contract error (422).

How Has This Been Tested?

  • Tested locally
  • Verified in development environment
  • Checked responsive layout (if UI-related)
  • Tested accessibility (keyboard / screen reader)

Manual checks:

  • Admin: created a new product with prices payload; product created successfully and price rows written to product_prices.
  • Admin: updated an existing product; upserted/updated product_prices correctly; legacy USD mirror remained consistent.
  • Cart rehydrate:
    • Accept-Language: uk returns items in UAH when UAH price exists.
    • Accept-Language: en returns items in USD when USD price exists.
    • When a product lacks a price for the resolved currency, API returns:
      • 422 {"code":"PRICE_CONFIG_ERROR","message":"Price not configured for currency.","details":{"productId":"...","currency":"..."}}

Screenshots (if applicable)

N/A (API/service changes)


Checklist

Before submitting

  • Code has been self-reviewed
  • No TypeScript or console errors
  • Code follows project conventions
  • Scope is limited to this feature/fix
  • No unrelated refactors included
  • English used in code, commits, and docs
  • New dependencies discussed with team
  • Database migration tested locally (if applicable)
  • GitHub Projects card moved to In Review

Reviewers

Summary by CodeRabbit

  • New Features

    • Product pages now render dynamically at runtime for up-to-date content.
  • Improvements

    • Migrated pricing to minor-unit representation with stricter validation and canonical parsing.
    • Added stronger data-integrity checks across products, prices, and orders.
    • Enhanced admin-side price parsing to better handle legacy and canonical payloads.
  • Bug Fixes

    • Removed legacy price-override handling from admin updates to simplify update flow.

✏️ Tip: You can customize this high-level summary in your review settings.

@netlify
Copy link
Copy Markdown

netlify Bot commented Jan 1, 2026

Deploy Preview for develop-devlovers ready!

Name Link
🔨 Latest commit 9d54952
🔍 Latest deploy log https://app.netlify.com/projects/develop-devlovers/deploys/6956e9ae4151f60008f949ea
😎 Deploy Preview https://deploy-preview-101--develop-devlovers.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 1, 2026

Warning

Rate limit exceeded

@liudmylasovetovs has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 6 minutes and 35 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between f7563f6 and 9d54952.

📒 Files selected for processing (1)
  • frontend/lib/services/products.ts

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

📝 Walkthrough

Walkthrough

Product pricing migrated from string major-units to numeric minor-units across services and DB; the product page now forces runtime dynamic rendering; admin PATCH no longer parses or applies price overrides; DB schema gains strict cross-field integrity checks for prices and orders.

Changes

Cohort / File(s) Summary
Routing & Page Generation
frontend/app/[locale]/shop/products/[slug]/page.tsx
Removed generateStaticParams() and catalog/locale static logic; added export const dynamic = "force-dynamic" for runtime rendering.
Admin API Endpoint
frontend/app/api/shop/admin/products/[id]/route.ts
Removed Zod price schemas and price-parsing/override logic; PATCH now updates using parsed admin fields directly and no longer processes price overrides from formData.
Database Schema & Constraints
frontend/db/schema/shop.ts
Added cross-field integrity checks and mirror validations (minor/major consistency), enforced USD/positive-price constraints, and made orders.pspMetadata non-null with default {}.
Admin Form Parsing
frontend/lib/admin/parseAdminProductForm.ts
Introduced parseMinorInt() and requirePositivePriceMinor(); parsePricesJsonField now prefers canonical minor-unit fields with legacy fallbacks and validates originalPriceMinor relations and currency rules.
Price Normalization & Services
frontend/lib/services/products.ts
Replaced string-major price model with NormalizedPriceRow using priceMinor/originalPriceMinor; updated normalization, validation (requireUsd, validatePriceRows) and create/update flows to persist/use minor-unit values.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant AdminUI as Admin UI
  participant AdminAPI as Admin API Route
  participant ProductSvc as Product Service
  participant DB as Database

  Note over AdminUI,AdminAPI: PATCH /api/shop/admin/products/[id]
  AdminUI->>AdminAPI: submit formData (may include prices JSON)
  AdminAPI->>AdminAPI: parse base admin fields (prices override parsing removed)
  AdminAPI->>ProductSvc: updateProduct(parsed.data, images)
  ProductSvc->>ProductSvc: normalize/validate priceMinor fields
  ProductSvc->>DB: UPDATE product, upsert productPrices with priceMinor/originalPriceMinor
  DB-->>ProductSvc: 200 OK
  ProductSvc-->>AdminAPI: updated product
  AdminAPI-->>AdminUI: 200 OK
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • AM1007
  • ViktorSvertoka

Poem

🐰 I nibble cents and count them true,
Strings turned numbers, clearer view.
Pages wake at runtime's call,
Admin trims the overrides small.
Hop, data hop — the prices fall in queue.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.56% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title references fixing DB issues in pricing validation and multi-currency storage, which aligns with the main changes of restructuring price handling, enforcing invariants, and stabilizing validation across multiple files.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (4)
frontend/lib/services/products.ts (1)

742-751: Consider simplifying the unit price extraction.

The conditional logic with type assertion (product as any).priceMinor is a defensive pattern, but the priceMinor field is already selected in the query (line 690). The fallback to coercePriceFromDb should only trigger for legacy data.

🔎 Suggested simplification
     const unitPriceCents =
-      typeof (product as any).priceMinor === 'number' &&
-      Number.isFinite((product as any).priceMinor)
-        ? Math.trunc((product as any).priceMinor)
+      typeof product.priceMinor === 'number' &&
+      Number.isFinite(product.priceMinor)
+        ? Math.trunc(product.priceMinor)
         : toCents(
             coercePriceFromDb(product.price, {
               field: 'price',
               productId: product.id,
             })
           );
frontend/app/api/shop/admin/products/[id]/route.ts (2)

22-80: Remove commented-out code.

Large blocks of commented-out code (schemas and helper functions) should be deleted rather than kept. If needed later, it can be recovered from version control.

🔎 Proposed cleanup
-// const adminCurrencySchema = z
-//   .string()
-//   .transform(v => v.trim().toUpperCase())
-//   .pipe(z.enum(['USD', 'UAH']));
-
-// function parseMajorToMinor(value: string | number): number {
-//   const s = String(value).trim().replace(',', '.');
-//
-//   if (!/^\d+(\.\d{1,2})?$/.test(s)) {
-//     throw new Error(`Invalid money value: "${value}"`);
-//   }
-//   const [whole, frac = ''] = s.split('.');
-//   const frac2 = (frac + '00').slice(0, 2);
-//   const minor = Number(whole) * 100 + Number(frac2);
-//   if (!Number.isSafeInteger(minor) || minor < 0) {
-//     throw new Error(`Invalid money value: "${value}"`);
-//   }
-//   return minor;
-// }
-
-// const minorRowSchema = z.object({
-//   currency: adminCurrencySchema,
-//   priceMinor: z.preprocess(
-//     v => (typeof v === 'string' ? Number(v) : v),
-//     z.number().int().nonnegative()
-//   ),
-//   originalPriceMinor: z.preprocess(v => {
-//     if (v === undefined) return undefined;
-//     if (v === null) return null;
-//     return typeof v === 'string' ? Number(v) : v;
-//   }, z.number().int().nonnegative().nullable().optional()),
-// });
-
-// const legacyRowSchema = z.object({
-//   currency: adminCurrencySchema,
-//   price: z.preprocess(v => String(v).trim(), z.string().min(1)),
-//   originalPrice: z.preprocess(v => {
-//     if (v === undefined) return undefined;
-//     if (v === null) return null;
-//     const s = String(v).trim();
-//     return s.length ? s : null;
-//   }, z.string().nullable().optional()),
-// });
-
-// const adminPriceRowSchema = z
-//   .union([minorRowSchema, legacyRowSchema])
-//   .transform(row => {
-//     if ('priceMinor' in row) {
-//       return row;
-//     }
-//     return {
-//       currency: row.currency,
-//       priceMinor: parseMajorToMinor(row.price),
-//       originalPriceMinor:
-//         row.originalPrice == null ? null : parseMajorToMinor(row.originalPrice),
-//     };
-//   });
-
-// const adminPricesSchema = z.array(adminPriceRowSchema);

156-162: Type assertion as any bypasses type safety.

The parsed.data as any cast suggests a type mismatch between parseAdminProductForm output and updateProduct input. Consider aligning the types or adding a proper type guard.

frontend/lib/admin/parseAdminProductForm.ts (1)

246-246: Minor: Remove extra blank line.

🔎 Proposed fix
-
 export function parseAdminProductForm(
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b6ac1e6 and 73e2181.

📒 Files selected for processing (5)
  • frontend/app/[locale]/shop/products/[slug]/page.tsx
  • frontend/app/api/shop/admin/products/[id]/route.ts
  • frontend/db/schema/shop.ts
  • frontend/lib/admin/parseAdminProductForm.ts
  • frontend/lib/services/products.ts
🧰 Additional context used
🧬 Code graph analysis (3)
frontend/app/[locale]/shop/products/[slug]/page.tsx (4)
frontend/app/[locale]/shop/admin/orders/[id]/page.tsx (1)
  • dynamic (9-9)
frontend/app/[locale]/shop/admin/orders/page.tsx (1)
  • dynamic (7-7)
frontend/app/[locale]/layout.tsx (1)
  • dynamic (15-15)
frontend/app/api/shop/orders/[id]/route.ts (1)
  • dynamic (12-12)
frontend/lib/admin/parseAdminProductForm.ts (1)
frontend/drizzle/schema.ts (1)
  • currency (7-7)
frontend/lib/services/products.ts (5)
frontend/lib/shop/currency.ts (2)
  • CurrencyCode (2-2)
  • currencyValues (1-1)
frontend/lib/validation/shop.ts (1)
  • CurrencyCode (32-32)
frontend/lib/services/errors.ts (1)
  • InvalidPayloadError (16-22)
frontend/drizzle/schema.ts (1)
  • currency (7-7)
frontend/lib/shop/money.ts (1)
  • toDbMoney (58-60)
🔇 Additional comments (21)
frontend/lib/services/products.ts (9)

53-57: LGTM!

The NormalizedPriceRow type correctly represents the canonical minor-unit pricing model with priceMinor as required integer and originalPriceMinor as optional.


138-150: LGTM!

The assertMoneyMinorInt helper properly validates minor units by:

  • Coercing string/number inputs
  • Checking for finite numbers
  • Truncating to integer and validating it's a safe positive integer (≥1)

188-269: Transitional price normalization logic looks solid.

The function handles three input paths correctly:

  1. NEW: prices[].priceMinor / originalPriceMinor (minor units)
  2. LEGACY: prices[].price / originalPrice (major strings → converted via toMoneyMinor)
  3. VERY LEGACY: top-level price/originalPrice/currency

Currency validation and duplicate prevention are handled downstream in validatePriceRows.


271-277: LGTM!

The requireUsd function correctly enforces the USD price requirement by checking for both existence and a truthy priceMinor value.


279-314: LGTM!

The validatePriceRows function properly enforces:

  • No duplicate currencies via Set
  • Currency membership in currencyValues
  • priceMinor must be a positive safe integer
  • originalPriceMinor (when present) must be a safe integer and greater than priceMinor

339-414: LGTM!

The createProduct function correctly:

  • Persists USD legacy mirror using toDbMoney(usd.priceMinor) and toDbMoney(usd.originalPriceMinor)
  • Stores canonical priceMinor/originalPriceMinor in productPrices along with legacy mirror columns

This aligns with the schema constraints requiring mirror consistency.


480-490: LGTM!

The USD legacy mirror update correctly uses the new minor-unit fields (usd.priceMinor, usd.originalPriceMinor) with toDbMoney conversion.


504-542: LGTM!

The upsert logic for productPrices correctly:

  • Stores both canonical minor units (priceMinor, originalPriceMinor)
  • Maintains legacy mirror columns via toDbMoney conversion
  • Uses update-then-insert pattern to handle existing/new currency rows

728-734: Critical pricing contract enforcement is correctly implemented.

The PriceConfigError is thrown when product_prices is missing for the resolved currency, enforcing the no-fallback contract as specified in the PR objectives.

frontend/app/[locale]/shop/products/[slug]/page.tsx (2)

8-8: LGTM!

Import cleanup is correct—only getProductPageData is needed for runtime data fetching.


13-13: Verify the performance impact of switching to dynamic rendering.

The change from static generation to force-dynamic ensures fresh pricing data per request, which aligns with the multi-currency pricing requirements. However, this removes the caching benefits of SSG.

Ensure this is intentional for product pages, as it may increase server load and TTFB. The pattern is consistent with other pages in the codebase (admin/orders, layout).

frontend/db/schema/shop.ts (4)

75-80: LGTM!

The products table constraints correctly enforce:

  • currency = 'USD' for the legacy mirror field
  • price > 0 (positive price requirement)
  • originalPrice IS NULL OR originalPrice > price (discount invariant)

These align with the application-level validations in products.ts.


116-152: LGTM!

The orders table changes correctly:

  • pspMetadata is now notNull with default '{}'::jsonb
  • Mirror consistency check: totalAmount = totalAmountMinor / 100
  • Payment status constraint: when paymentProvider = 'none', status must be 'paid'

196-203: LGTM!

The orderItems mirror consistency checks correctly enforce:

  • unitPrice = unitPriceMinor / 100
  • lineTotal = lineTotalMinor / 100

262-273: LGTM!

The productPrices constraints correctly enforce:

  • Mirror consistency: price = priceMinor / 100
  • Null coupling: originalPriceMinor IS NULL ↔ originalPrice IS NULL
  • Original price mirror: originalPrice = originalPriceMinor / 100 when not null

These database-level constraints provide defense-in-depth for the minor-unit pricing model.

frontend/lib/admin/parseAdminProductForm.ts (6)

115-133: LGTM!

The parseMinorInt helper correctly validates:

  • Accepts number or numeric string
  • Must be finite, integer, and non-negative (≥0)
  • Returns null for null/undefined inputs

135-141: LGTM!

The requirePositivePriceMinor helper enforces the DB constraint priceMinor > 0 for create mode.


148-154: LGTM!

Improved error handling returns a structured error object instead of throwing, allowing consistent error propagation.


185-206: LGTM!

The canonical minor-unit parsing with legacy fallback is correctly implemented:

  1. Try priceMinor (canonical)
  2. Fall back to price (legacy major → minor conversion)
  3. In create mode, enforce positive price
  4. In update mode, allow null but reject non-positive values if present

208-230: LGTM!

The originalPriceMinor handling correctly:

  • Prefers canonical originalPriceMinor
  • Falls back to legacy originalPrice conversion
  • Normalizes empty to null
  • Enforces DB invariant: originalPriceMinor > priceMinor when both present

240-243: LGTM!

Error handling correctly propagates ZodError instances and wraps other errors in a structured format.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread frontend/lib/services/products.ts Outdated
Comment on lines +139 to +143
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) {
throw new InvalidPayloadError(`${field} must be a number.`);
}
const i = Math.trunc(n);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject fractional minor-unit prices instead of truncating

When a client sends minor-unit pricing, assertMoneyMinorInt truncates fractional values via Math.trunc, so a payload like priceMinor: 199.99 becomes 199 rather than being rejected. This silently undercharges and can desync totals (e.g., cart totals vs. admin intent) if any caller sends non-integer minor units. Consider validating Number.isInteger(n) and rejecting non-integer inputs so bad payloads fail instead of being rounded down.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (1)
frontend/lib/services/products.ts (1)

138-150: Fractional minor units are truncated rather than rejected.

As previously noted, Math.trunc on line 143 silently rounds down non-integer inputs (e.g., 199.99 becomes 199), which can cause pricing discrepancies between admin intent and stored values. Consider adding Number.isInteger(n) check before truncation and rejecting non-integer inputs.

🧹 Nitpick comments (1)
frontend/lib/services/products.ts (1)

742-751: Consider rejecting fractional priceMinor from DB instead of truncating.

Line 745 uses Math.trunc(product.priceMinor) which silently rounds down fractional values. While the DB schema should prevent non-integer minor units, truncating here could mask data integrity issues. Consider validating Number.isInteger(product.priceMinor) and throwing an error if fractional values are found.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 73e2181 and 1bbaa5f.

📒 Files selected for processing (3)
  • frontend/app/api/shop/admin/products/[id]/route.ts
  • frontend/lib/admin/parseAdminProductForm.ts
  • frontend/lib/services/products.ts
🧰 Additional context used
🧬 Code graph analysis (1)
frontend/lib/admin/parseAdminProductForm.ts (1)
frontend/drizzle/schema.ts (1)
  • currency (7-7)
🔇 Additional comments (9)
frontend/lib/services/products.ts (4)

188-269: LGTM: Transitional input handling is well-structured.

The three-tier fallback (NEW minor units → LEGACY major strings → top-level legacy) correctly supports the migration path while maintaining backward compatibility. Currency validation and format handling are comprehensive.


279-314: LGTM: Comprehensive runtime validation.

The validation correctly enforces all invariants: unique currencies, valid currency codes, positive integer minor units, and originalPriceMinor > priceMinor. The runtime guards are appropriate for transitional input that may bypass TypeScript/Zod validation.


316-414: LGTM: Create flow correctly uses minor units.

The transaction properly maintains both the USD legacy mirror (lines 351-355) and canonical multi-currency prices (lines 375-394) using minor-unit values. Validation sequence is correct.


416-574: LGTM: Update flow correctly uses minor units with PATCH semantics.

The partial-update logic appropriately preserves existing prices when not provided (lines 473-490) and upserts only the currencies included in the request (lines 504-542). Minor-unit handling is consistent.

frontend/app/api/shop/admin/products/[id]/route.ts (1)

64-165: LGTM: Simplified PATCH handler delegates price normalization upstream.

Removing inline price parsing/validation from the route and relying on parseAdminProductForm improves separation of concerns. The direct spread of parsed.data at line 97 is appropriate since validation is complete at that point.

frontend/lib/admin/parseAdminProductForm.ts (4)

115-133: LGTM: parseMinorInt correctly validates non-negative integers.

The function appropriately validates that the value is a finite integer >= 0. The acceptance of 0 is fine here since requirePositivePriceMinor and the update-mode validation (lines 199-206) enforce the > 0 constraint where needed.


135-141: LGTM: requirePositivePriceMinor correctly enforces positive price constraint.

The validation appropriately rejects null or non-positive values, matching the DB constraint.


143-244: LGTM: parsePricesJsonField handles canonical and legacy pricing correctly.

The function properly supports both minor-unit canonical payloads and legacy major-string fallbacks (lines 186-197, 208-218), validates the originalPriceMinor > priceMinor invariant (lines 224-230), and correctly differentiates create vs. update validation (lines 199-206). The validation at lines 224-230 appropriately checks the invariant only when both values are present in the payload, as DB constraints and downstream validation will catch any issues with partial updates.


246-330: LGTM: parseAdminProductForm correctly prioritizes canonical prices with legacy fallback.

The function appropriately prefers the canonical prices JSON payload (line 263), falls back to legacy priceUsd/priceUah fields (lines 269-294), and correctly implements PATCH semantics for update mode (lines 297-302). The final payload construction (lines 304-318) conditionally includes prices only when available.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
frontend/lib/services/products.ts (1)

212-230: Missing originalPriceMinor > priceMinor invariant check in the NEW path.

The LEGACY path enforces originalPrice > price via toMoneyMinorNullable during normalization, but the NEW path (lines 218-227) only validates that originalPriceMinor is a valid integer without checking the invariant. While validatePriceRows (line 314) catches this later, enforcing it here would provide earlier, more contextual error messages and maintain consistency between paths.

🔎 Proposed fix
       // NEW path: minor units
       if (p?.priceMinor != null) {
         const priceMinor = assertMoneyMinorInt(
           p.priceMinor,
           `${currency} price`
         );
         const originalPriceMinor =
           p.originalPriceMinor == null
             ? null
             : (() => {
                 const v = assertMoneyMinorInt(
                   p.originalPriceMinor,
                   `${currency} originalPrice`
                 );
+                if (v <= priceMinor) {
+                  throw new InvalidPayloadError(
+                    `${currency} originalPrice must be > price.`
+                  );
+                }
                 return v;
               })();

         return { currency, priceMinor, originalPriceMinor };
       }
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1bbaa5f and f7563f6.

📒 Files selected for processing (1)
  • frontend/lib/services/products.ts
🧰 Additional context used
🧬 Code graph analysis (1)
frontend/lib/services/products.ts (5)
frontend/lib/shop/currency.ts (2)
  • CurrencyCode (2-2)
  • currencyValues (1-1)
frontend/lib/services/errors.ts (2)
  • InvalidPayloadError (16-22)
  • PriceConfigError (24-34)
frontend/drizzle/schema.ts (1)
  • currency (7-7)
frontend/lib/shop/money.ts (2)
  • toDbMoney (58-60)
  • toCents (38-42)
frontend/db/queries/shop/orders.ts (1)
  • coercePriceFromDb (35-53)
🔇 Additional comments (7)
frontend/lib/services/products.ts (7)

53-57: LGTM!

The NormalizedPriceRow type correctly models minor-unit pricing with priceMinor as a required number and originalPriceMinor as nullable for optional sale pricing.


138-157: LGTM! Fractional minor-unit rejection implemented correctly.

The validation now properly rejects non-integer minor units via Number.isInteger(n) check (line 146) instead of truncating, which addresses the previous review feedback. The validation order is correct: finite → integer → safe integer and positive.


278-284: LGTM!

The falsy check !usd?.priceMinor is safe here because validatePriceRows is called before requireUsd in createProduct, ensuring priceMinor >= 1.


286-321: LGTM!

Comprehensive validation covering all pricing invariants: duplicate currencies, valid currency codes, positive safe integer for priceMinor, and the originalPriceMinor > priceMinor constraint. The runtime guards provide defense-in-depth against bypassed upstream validation.


358-401: LGTM!

The transaction correctly inserts both the canonical priceMinor/originalPriceMinor values and the legacy toDbMoney representations, maintaining backward compatibility while supporting the new minor-unit model.


489-548: LGTM!

The upsert pattern correctly handles partial price updates, updating only the currencies provided in the request. The dual-write to both canonical (priceMinor) and legacy (price) columns maintains backward compatibility.


749-789: LGTM! Robust DB validation with defense-in-depth.

The cart rehydration now enforces strict integer validation on priceMinor from the database, throwing PriceConfigError with full context (productId, currency) for invalid data. The fallback to the legacy price column (lines 773-781) provides transitional compatibility for products not yet migrated to the canonical priceMinor field. The final safety check (lines 784-789) ensures consistency regardless of the source.

Consider adding a TODO or logging when the fallback path is taken to track migration progress:

     } else {
       // Fallback to legacy money column (string/decimal), still validated via coercePriceFromDb
+      // TODO: Remove fallback after full migration to priceMinor
       unitPriceCents = toCents(
         coercePriceFromDb(product.price, {
           field: 'price',
           productId: product.id,
         })
       );
     }

Copy link
Copy Markdown
Member

@ViktorSvertoka ViktorSvertoka left a comment

Choose a reason for hiding this comment

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

Great job!

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.

2 participants