Merged PR 1989: fix(checkout): resolve currency constraint violations in price handling

fix(checkout): resolve currency constraint violations in price handling

- Add ensureCurrencyDefaults() helper to normalize price objects with EUR defaults
- Fix currency constraint violation in shopping cart item additions (bug #5405)
- Apply price normalization across availability, checkout, and shopping cart services
- Update 8 locations: availability.adapter, checkout.service, shopping-cart.service,
  get-availability-params.adapter, availability-transformers, reward quantity control
- Refactor OrderType to @isa/common/data-access for cross-domain reusability
- Remove duplicate availability service from catalogue library
- Enhance PriceValue and VatValue schemas with proper currency defaults
- Add availability-transformers.spec.ts test coverage
- Fix QuantityControl fallback from 0 to 1 to prevent invalid state warnings

Resolves issue where POST requests to /checkout/v6/store/shoppingcart/{id}/item
were sending price objects without required currency/currencySymbol fields,
causing 400 Bad Request with 'Currency: Constraint violation: NotNull' error.

Related work items: #5405
This commit is contained in:
Lorenz Hilpert
2025-10-28 10:34:39 +00:00
committed by Nino Righi
parent 2d654aa63a
commit bfd151dd84
24 changed files with 1185 additions and 1170 deletions

View File

@@ -122,11 +122,6 @@ export class GetAvailabilityParamsAdapter {
item: ShoppingCartItem,
orderType = getOrderTypeFeature(item.features),
): GetSingleItemAvailabilityInputParams | undefined {
console.log(
'Transforming ShoppingCartItem to single-item availability params',
orderType,
);
// Extract common data
const itemData = this.extractItemData(item);
if (!itemData) {
@@ -205,8 +200,8 @@ export class GetAvailabilityParamsAdapter {
? {
value: item.availability.price.value ?? {
value: undefined,
currency: undefined,
currencySymbol: undefined,
currency: 'EUR',
currencySymbol: '€',
},
vat: item.availability.price.vat ?? {
value: undefined,

View File

@@ -86,40 +86,6 @@ export async function executeAvailabilityApiCall<T>(
return res.result as T;
}
/**
* Logs the result of an availability check with standardized format.
*
* This helper centralizes success logging for availability operations,
* showing how many items were requested vs available.
*
* @param orderType - Order type that was checked (e.g., 'Versand', 'DIG-Versand')
* @param requestedItemCount - Number of items that were requested
* @param availableItemCount - Number of items that are available
* @param additionalContext - Optional additional context (e.g., shopId, branchId)
*
* @example
* ```typescript
* logAvailabilityResult('Versand', 5, 4, { shopId: 42 });
* // Logs: "Versand availability fetched: 4/5 items available"
* ```
*/
export function logAvailabilityResult(
orderType: string,
requestedItemCount: number,
availableItemCount: number,
additionalContext?: Record<string, unknown>,
): void {
// Logging disabled in helper functions due to injection context limitations in tests
// TODO: Pass logger instance from service if logging is required
// const unavailableCount = requestedItemCount - availableItemCount;
// getApiLogger().info(`${orderType} availability fetched`, () => ({
// requestedItems: requestedItemCount,
// availableItems: availableItemCount,
// unavailableItems: unavailableCount,
// ...additionalContext,
// }));
}
/**
* Safely converts an itemId to a string for use as dictionary key.
*

View File

@@ -0,0 +1,508 @@
import { describe, it, expect } from 'vitest';
import {
transformAvailabilitiesToDictionary,
transformAvailabilitiesToDictionaryWithFieldFilter,
transformDownloadAvailabilitiesToDictionary,
transformStockToAvailability,
} from './availability-transformers';
import { Availability, AvailabilityType } from '../models';
import { StockInfo } from '@isa/remission/data-access';
import { Supplier } from '@isa/checkout/data-access';
// ============================================================================
// Mock Data Helpers
// ============================================================================
function mockAvailability(
itemId: number,
overrides: Partial<Availability> = {},
): Availability {
return {
itemId,
status: AvailabilityType.Available,
preferred: 1,
qty: 5,
supplierId: 1,
at: '2025-10-15',
altAt: '2025-10-20',
requestStatusCode: '0',
price: {
value: { value: 19.99, currency: 'EUR', currencySymbol: '€' },
vat: { value: 3.8, inPercent: 19, label: '19%', vatType: 1 },
},
priceMaintained: true,
ssc: '1024',
sscText: 'Available',
...overrides,
};
}
function mockStockInfo(
itemId: number,
overrides: Partial<StockInfo> = {},
): StockInfo {
return {
itemId,
inStock: 10,
retailPrice: 19.99,
...overrides,
};
}
function mockSupplier(overrides: Partial<Supplier> = {}): Supplier {
return {
id: 'F',
name: 'Filiale',
...overrides,
};
}
// ============================================================================
// Tests: transformAvailabilitiesToDictionary
// ============================================================================
describe('transformAvailabilitiesToDictionary', () => {
it('should return empty dictionary for empty availabilities array', () => {
const result = transformAvailabilitiesToDictionary(
[],
[{ itemId: 123 }],
);
expect(result).toEqual({});
});
it('should return empty dictionary for empty requested items', () => {
const availabilities = [mockAvailability(123)];
const result = transformAvailabilitiesToDictionary(availabilities, []);
expect(result).toEqual({});
});
it('should skip items without itemId', () => {
const availabilities = [
mockAvailability(123),
mockAvailability(456),
];
const requestedItems = [
{ itemId: 123 },
{}, // Missing itemId
{ itemId: 456 },
];
const result = transformAvailabilitiesToDictionary(
availabilities,
requestedItems,
);
expect(result).toHaveProperty('123');
expect(result).toHaveProperty('456');
expect(Object.keys(result)).toHaveLength(2);
});
it('should transform single item correctly', () => {
const availability = mockAvailability(123, { preferred: 1 });
const result = transformAvailabilitiesToDictionary(
[availability],
[{ itemId: 123 }],
);
expect(result).toEqual({ '123': availability });
});
it('should transform multiple items correctly', () => {
const availability1 = mockAvailability(123, { preferred: 1 });
const availability2 = mockAvailability(456, { preferred: 1 });
const result = transformAvailabilitiesToDictionary(
[availability1, availability2],
[{ itemId: 123 }, { itemId: 456 }],
);
expect(result).toEqual({
'123': availability1,
'456': availability2,
});
});
it('should exclude item when no preferred availability found', () => {
const availability = mockAvailability(123, { preferred: 0 });
const result = transformAvailabilitiesToDictionary(
[availability],
[{ itemId: 123 }],
);
expect(result).toEqual({});
});
it('should match itemIds correctly with type coercion', () => {
const availability = mockAvailability(123, { preferred: 1 });
const result = transformAvailabilitiesToDictionary(
[availability],
[{ itemId: 123 }],
);
// Key should be string, but matches number itemId
expect(result).toHaveProperty('123');
expect(result['123'].itemId).toBe(123);
});
});
// ============================================================================
// Tests: transformAvailabilitiesToDictionaryWithFieldFilter
// ============================================================================
describe('transformAvailabilitiesToDictionaryWithFieldFilter', () => {
it('should exclude supplierId field from result', () => {
const availability = mockAvailability(123, {
preferred: 1,
supplierId: 5,
});
const result = transformAvailabilitiesToDictionaryWithFieldFilter(
[availability],
[{ itemId: 123 }],
);
expect(result['123']).toBeDefined();
expect(result['123']).not.toHaveProperty('supplierId');
});
it('should exclude all supplier and logistician fields', () => {
const availability = mockAvailability(123, {
preferred: 1,
supplierId: 5,
supplier: { id: 5, name: 'Test Supplier' },
logisticianId: 10,
logistician: { id: 10, name: 'Test Logistician' },
});
const result = transformAvailabilitiesToDictionaryWithFieldFilter(
[availability],
[{ itemId: 123 }],
);
const transformed = result['123'];
expect(transformed).toBeDefined();
expect(transformed).not.toHaveProperty('supplierId');
expect(transformed).not.toHaveProperty('supplier');
expect(transformed).not.toHaveProperty('logisticianId');
expect(transformed).not.toHaveProperty('logistician');
});
it('should preserve all other availability fields', () => {
const availability = mockAvailability(123, {
preferred: 1,
supplierId: 5,
qty: 10,
status: AvailabilityType.Available,
price: {
value: { value: 29.99, currency: 'EUR', currencySymbol: '€' },
vat: { value: 5.7, inPercent: 19, label: '19%', vatType: 1 },
},
});
const result = transformAvailabilitiesToDictionaryWithFieldFilter(
[availability],
[{ itemId: 123 }],
);
const transformed = result['123'];
expect(transformed.itemId).toBe(123);
expect(transformed.qty).toBe(10);
expect(transformed.status).toBe(AvailabilityType.Available);
expect(transformed.price?.value?.value).toBe(29.99);
expect(transformed.at).toBe('2025-10-15');
});
it('should return empty dictionary for empty availabilities', () => {
const result = transformAvailabilitiesToDictionaryWithFieldFilter(
[],
[{ itemId: 123 }],
);
expect(result).toEqual({});
});
it('should skip items without itemId', () => {
const availabilities = [mockAvailability(123)];
const requestedItems = [{ itemId: 123 }, {}];
const result = transformAvailabilitiesToDictionaryWithFieldFilter(
availabilities,
requestedItems,
);
expect(Object.keys(result)).toHaveLength(1);
expect(result).toHaveProperty('123');
});
it('should exclude item when no preferred availability', () => {
const availability = mockAvailability(123, { preferred: 0 });
const result = transformAvailabilitiesToDictionaryWithFieldFilter(
[availability],
[{ itemId: 123 }],
);
expect(result).toEqual({});
});
it('should filter fields for multiple items', () => {
const availability1 = mockAvailability(123, {
preferred: 1,
supplierId: 5,
});
const availability2 = mockAvailability(456, {
preferred: 1,
supplierId: 8,
});
const result = transformAvailabilitiesToDictionaryWithFieldFilter(
[availability1, availability2],
[{ itemId: 123 }, { itemId: 456 }],
);
expect(result['123']).not.toHaveProperty('supplierId');
expect(result['456']).not.toHaveProperty('supplierId');
});
it('should handle availabilities without supplier fields gracefully', () => {
const availability = mockAvailability(123, {
preferred: 1,
supplierId: undefined,
supplier: undefined,
logisticianId: undefined,
logistician: undefined,
});
const result = transformAvailabilitiesToDictionaryWithFieldFilter(
[availability],
[{ itemId: 123 }],
);
expect(result['123']).toBeDefined();
expect(result['123'].itemId).toBe(123);
});
});
// ============================================================================
// Tests: transformDownloadAvailabilitiesToDictionary
// ============================================================================
describe('transformDownloadAvailabilitiesToDictionary', () => {
it('should include item with valid download availability', () => {
const availability = mockAvailability(123, {
preferred: 1,
supplierId: 1,
qty: 5,
status: AvailabilityType.Available, // 1024
});
const result = transformDownloadAvailabilitiesToDictionary(
[availability],
[{ itemId: 123 }],
);
expect(result).toEqual({ '123': availability });
});
it('should exclude supplier 16 with zero stock', () => {
const availability = mockAvailability(123, {
preferred: 1,
supplierId: 16,
qty: 0,
status: AvailabilityType.Available,
});
const result = transformDownloadAvailabilitiesToDictionary(
[availability],
[{ itemId: 123 }],
);
expect(result).toEqual({});
});
it('should include supplier 16 with positive stock', () => {
const availability = mockAvailability(123, {
preferred: 1,
supplierId: 16,
qty: 5,
status: AvailabilityType.Available,
});
const result = transformDownloadAvailabilitiesToDictionary(
[availability],
[{ itemId: 123 }],
);
expect(result).toEqual({ '123': availability });
});
it('should exclude item with invalid status code', () => {
const availability = mockAvailability(123, {
preferred: 1,
status: AvailabilityType.NotAvailable, // 1 (invalid for downloads)
});
const result = transformDownloadAvailabilitiesToDictionary(
[availability],
[{ itemId: 123 }],
);
expect(result).toEqual({});
});
it('should accept all valid download status codes', () => {
const validStatusCodes = [
AvailabilityType.PrebookAtBuyer, // 2
AvailabilityType.PrebookAtRetailer, // 32
AvailabilityType.PrebookAtSupplier, // 256
AvailabilityType.Available, // 1024
AvailabilityType.OnDemand, // 2048
AvailabilityType.AtProductionDate, // 4096
];
validStatusCodes.forEach((status, index) => {
const availability = mockAvailability(100 + index, {
preferred: 1,
status,
supplierId: 1,
qty: 5,
});
const result = transformDownloadAvailabilitiesToDictionary(
[availability],
[{ itemId: 100 + index }],
);
expect(result).toHaveProperty(String(100 + index));
});
});
it('should return empty dictionary for empty availabilities', () => {
const result = transformDownloadAvailabilitiesToDictionary(
[],
[{ itemId: 123 }],
);
expect(result).toEqual({});
});
it('should include only valid downloads in mixed scenario', () => {
const validAvailability = mockAvailability(123, {
preferred: 1,
status: AvailabilityType.Available,
supplierId: 1,
});
const invalidAvailability = mockAvailability(456, {
preferred: 1,
status: AvailabilityType.Available,
supplierId: 16,
qty: 0, // Invalid: supplier 16 with zero stock
});
const result = transformDownloadAvailabilitiesToDictionary(
[validAvailability, invalidAvailability],
[{ itemId: 123 }, { itemId: 456 }],
);
expect(result).toHaveProperty('123');
expect(result).not.toHaveProperty('456');
});
});
// ============================================================================
// Tests: transformStockToAvailability
// ============================================================================
describe('transformStockToAvailability', () => {
it('should transform item with stock to available', () => {
const stock = mockStockInfo(123, {
inStock: 5,
retailPrice: 19.99,
});
const supplier = mockSupplier({ id: 'F', name: 'Filiale' });
const result = transformStockToAvailability([stock], [123], supplier);
expect(result['123']).toEqual({
status: AvailabilityType.Available,
itemId: 123,
qty: 5,
ssc: '999',
sscText: 'Filialentnahme',
supplierId: 'F',
price: 19.99,
});
});
it('should transform item without stock to not available', () => {
const stock = mockStockInfo(123, {
inStock: 0,
retailPrice: 19.99,
});
const supplier = mockSupplier();
const result = transformStockToAvailability([stock], [123], supplier);
expect(result['123']).toEqual({
status: AvailabilityType.NotAvailable,
itemId: 123,
qty: 0,
ssc: '',
sscText: '',
supplierId: 'F',
price: 19.99,
});
});
it('should treat undefined inStock as zero', () => {
const stock = mockStockInfo(123, {
inStock: undefined,
retailPrice: 19.99,
});
const supplier = mockSupplier();
const result = transformStockToAvailability([stock], [123], supplier);
expect(result['123'].qty).toBe(0);
expect(result['123'].status).toBe(AvailabilityType.NotAvailable);
});
it('should exclude item when stock info not found', () => {
const stock = mockStockInfo(123, { inStock: 5 });
const supplier = mockSupplier();
const result = transformStockToAvailability([stock], [456], supplier);
expect(result).toEqual({});
});
it('should transform multiple items correctly', () => {
const stocks = [
mockStockInfo(123, { inStock: 5, retailPrice: 19.99 }),
mockStockInfo(456, { inStock: 0, retailPrice: 29.99 }),
];
const supplier = mockSupplier();
const result = transformStockToAvailability(
stocks,
[123, 456],
supplier,
);
expect(result['123'].status).toBe(AvailabilityType.Available);
expect(result['123'].qty).toBe(5);
expect(result['456'].status).toBe(AvailabilityType.NotAvailable);
expect(result['456'].qty).toBe(0);
});
it('should map supplier id correctly', () => {
const stock = mockStockInfo(123, { inStock: 5 });
const supplier = mockSupplier({ id: 'TEST_ID', name: 'Test' });
const result = transformStockToAvailability([stock], [123], supplier);
expect(result['123'].supplierId).toBe('TEST_ID');
});
});

View File

@@ -1,6 +1,7 @@
import { Availability, AvailabilityType } from '../models';
import { StockInfo } from '@isa/remission/data-access';
import { Supplier } from '@isa/checkout/data-access';
import { ensureCurrencyDefaults } from '@isa/common/data-access';
import { selectPreferredAvailability, isDownloadAvailable } from './availability.helpers';
import { logger } from '@isa/core/logging';
@@ -209,7 +210,7 @@ export function transformStockToAvailability(
ssc: isAvailable ? '999' : '',
sscText: isAvailable ? 'Filialentnahme' : '',
supplierId: supplier?.id,
price: stockInfo.retailPrice,
price: ensureCurrencyDefaults(stockInfo.retailPrice),
} satisfies Availability;
}

View File

@@ -1 +1 @@
export { OrderType } from '@isa/checkout/data-access';
export { OrderType } from '@isa/common/data-access';

View File

@@ -37,7 +37,6 @@ import {
transformDownloadAvailabilitiesToDictionary,
transformStockToAvailability,
executeAvailabilityApiCall,
logAvailabilityResult,
convertSingleItemToBatchParams,
extractItemIdFromSingleParams,
} from '../helpers';
@@ -217,12 +216,6 @@ export class AvailabilityService {
params.items,
);
logAvailabilityResult(
'Pickup',
params.items.length,
Object.keys(result).length,
);
return result;
}
@@ -252,12 +245,6 @@ export class AvailabilityService {
params.items,
);
logAvailabilityResult(
'Delivery',
params.items.length,
Object.keys(result).length,
);
return result;
}
@@ -284,12 +271,6 @@ export class AvailabilityService {
params.items,
);
logAvailabilityResult(
'DIG-Versand',
params.items.length,
Object.keys(result).length,
);
return result;
}
@@ -351,16 +332,6 @@ export class AvailabilityService {
}));
}
logAvailabilityResult(
'B2B-Versand',
params.items.length,
Object.keys(result).length,
{
shopId: defaultBranch.id,
logisticianId: logistician.id,
},
);
return result;
}
@@ -391,12 +362,6 @@ export class AvailabilityService {
params.items,
);
logAvailabilityResult(
'Download',
params.items.length,
Object.keys(result).length,
);
return result;
}