feat: add acquisition and pricing domain rules
This commit is contained in:
@@ -0,0 +1,64 @@
|
|||||||
|
function requireNonNegativeMinorUnit(value, fieldName) {
|
||||||
|
if (!Number.isInteger(value) || value < 0) {
|
||||||
|
throw new Error(`${fieldName} must be a non-negative integer minor unit.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredText(value, fieldName) {
|
||||||
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||||
|
throw new Error(`${fieldName} is required.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAcquisition({
|
||||||
|
inventoryItemId,
|
||||||
|
method,
|
||||||
|
costMinor,
|
||||||
|
currency = 'USD',
|
||||||
|
date,
|
||||||
|
source,
|
||||||
|
location,
|
||||||
|
shippingMinor = 0,
|
||||||
|
taxMinor = 0,
|
||||||
|
otherFeesMinor = 0,
|
||||||
|
estimatedTradeValueMinor = 0,
|
||||||
|
notes,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'),
|
||||||
|
method: requiredText(method, 'Acquisition method'),
|
||||||
|
costMinor: requireNonNegativeMinorUnit(costMinor, 'Acquisition cost'),
|
||||||
|
currency: requiredText(currency, 'Currency'),
|
||||||
|
...(date ? { date } : {}),
|
||||||
|
...(typeof source === 'string' && source.trim().length > 0 ? { source: source.trim() } : {}),
|
||||||
|
...(typeof location === 'string' && location.trim().length > 0 ? { location: location.trim() } : {}),
|
||||||
|
shippingMinor: requireNonNegativeMinorUnit(shippingMinor, 'Shipping cost'),
|
||||||
|
taxMinor: requireNonNegativeMinorUnit(taxMinor, 'Tax'),
|
||||||
|
otherFeesMinor: requireNonNegativeMinorUnit(otherFeesMinor, 'Other fees'),
|
||||||
|
estimatedTradeValueMinor: requireNonNegativeMinorUnit(
|
||||||
|
estimatedTradeValueMinor,
|
||||||
|
'Estimated trade value',
|
||||||
|
),
|
||||||
|
...(typeof notes === 'string' && notes.trim().length > 0 ? { notes: notes.trim() } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateAcquisitionCostBasis({
|
||||||
|
costMinor,
|
||||||
|
shippingMinor = 0,
|
||||||
|
taxMinor = 0,
|
||||||
|
otherFeesMinor = 0,
|
||||||
|
estimatedTradeValueMinor = 0,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
requireNonNegativeMinorUnit(costMinor, 'Acquisition cost') +
|
||||||
|
requireNonNegativeMinorUnit(shippingMinor, 'Shipping cost') +
|
||||||
|
requireNonNegativeMinorUnit(taxMinor, 'Tax') +
|
||||||
|
requireNonNegativeMinorUnit(otherFeesMinor, 'Other fees') -
|
||||||
|
requireNonNegativeMinorUnit(estimatedTradeValueMinor, 'Estimated trade value')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
function requiredText(value, fieldName) {
|
||||||
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||||
|
throw new Error(`${fieldName} is required.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirePositiveMinorUnit(value, fieldName) {
|
||||||
|
if (!Number.isInteger(value) || value <= 0) {
|
||||||
|
throw new Error(`${fieldName} must be a positive integer minor unit.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPricingObservation({
|
||||||
|
productId,
|
||||||
|
inventoryItemId,
|
||||||
|
providerId,
|
||||||
|
priceMinor,
|
||||||
|
currency = 'USD',
|
||||||
|
sourceIdentifier,
|
||||||
|
conditionId,
|
||||||
|
sourceUrl,
|
||||||
|
qualityScore,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
productId: requiredText(productId, 'Product ID'),
|
||||||
|
inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'),
|
||||||
|
providerId: requiredText(providerId, 'Provider ID'),
|
||||||
|
priceMinor: requirePositiveMinorUnit(priceMinor, 'Observed price'),
|
||||||
|
currency: requiredText(currency, 'Currency'),
|
||||||
|
sourceIdentifier: requiredText(sourceIdentifier, 'Source identifier'),
|
||||||
|
conditionId: requiredText(conditionId, 'Condition ID'),
|
||||||
|
...(typeof sourceUrl === 'string' && sourceUrl.trim().length > 0 ? { sourceUrl: sourceUrl.trim() } : {}),
|
||||||
|
...(typeof qualityScore === 'number' ? { qualityScore } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateMedianPriceMinor(prices) {
|
||||||
|
if (!Array.isArray(prices) || prices.length === 0) {
|
||||||
|
throw new Error('At least one price is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...prices].sort((left, right) => left - right);
|
||||||
|
const middle = Math.floor(sorted.length / 2);
|
||||||
|
|
||||||
|
if (sorted.length % 2 === 0) {
|
||||||
|
return Math.round((sorted[middle - 1] + sorted[middle]) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted[middle];
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
calculateAcquisitionCostBasis,
|
||||||
|
createAcquisition,
|
||||||
|
} from '../../src/modules/acquisitions/domain/acquisition.js';
|
||||||
|
import {
|
||||||
|
calculateMedianPriceMinor,
|
||||||
|
createPricingObservation,
|
||||||
|
} from '../../src/modules/pricing/domain/pricing-observation.js';
|
||||||
|
|
||||||
|
test('calculates acquisition cost basis from all cost components', () => {
|
||||||
|
const total = calculateAcquisitionCostBasis({
|
||||||
|
costMinor: 1000,
|
||||||
|
shippingMinor: 150,
|
||||||
|
taxMinor: 75,
|
||||||
|
otherFeesMinor: 50,
|
||||||
|
estimatedTradeValueMinor: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(total, 1075);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects negative acquisition values and missing identity fields', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => createAcquisition({
|
||||||
|
inventoryItemId: 'inventory-item-id',
|
||||||
|
method: 'PURCHASE',
|
||||||
|
costMinor: -1,
|
||||||
|
}),
|
||||||
|
/must be a non-negative integer minor unit/,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => createAcquisition({
|
||||||
|
inventoryItemId: '',
|
||||||
|
method: 'PURCHASE',
|
||||||
|
costMinor: 1000,
|
||||||
|
}),
|
||||||
|
/Inventory item ID is required/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates pricing observations and calculates a median observed price', () => {
|
||||||
|
const observation = createPricingObservation({
|
||||||
|
productId: 'product-id',
|
||||||
|
inventoryItemId: 'inventory-item-id',
|
||||||
|
providerId: 'ebay',
|
||||||
|
priceMinor: 2499,
|
||||||
|
currency: 'USD',
|
||||||
|
sourceIdentifier: 'listing-1',
|
||||||
|
conditionId: 'condition-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(observation.currency, 'USD');
|
||||||
|
assert.equal(observation.priceMinor, 2499);
|
||||||
|
assert.equal(calculateMedianPriceMinor([1199, 2499, 2000]), 2000);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user