From 8576c8f788456f71569c78fc16cca16fea04832c Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Tue, 8 Sep 2026 17:51:51 -0400 Subject: [PATCH] feat: add acquisition and pricing domain rules --- .../acquisitions/domain/acquisition.js | 64 +++++++++++++++++++ .../pricing/domain/pricing-observation.js | 54 ++++++++++++++++ .../tests/domain/acquisition-pricing.test.js | 59 +++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 apps/api/src/modules/acquisitions/domain/acquisition.js create mode 100644 apps/api/src/modules/pricing/domain/pricing-observation.js create mode 100644 apps/api/tests/domain/acquisition-pricing.test.js diff --git a/apps/api/src/modules/acquisitions/domain/acquisition.js b/apps/api/src/modules/acquisitions/domain/acquisition.js new file mode 100644 index 0000000..adfc49d --- /dev/null +++ b/apps/api/src/modules/acquisitions/domain/acquisition.js @@ -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') + ); +} diff --git a/apps/api/src/modules/pricing/domain/pricing-observation.js b/apps/api/src/modules/pricing/domain/pricing-observation.js new file mode 100644 index 0000000..3fad64b --- /dev/null +++ b/apps/api/src/modules/pricing/domain/pricing-observation.js @@ -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]; +} diff --git a/apps/api/tests/domain/acquisition-pricing.test.js b/apps/api/tests/domain/acquisition-pricing.test.js new file mode 100644 index 0000000..b80533b --- /dev/null +++ b/apps/api/tests/domain/acquisition-pricing.test.js @@ -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); +});