Add robust outlier detection, stale valuation metadata, and audited seller-scoped market-value overrides with migration and tests.
This commit is contained in:
@@ -289,6 +289,16 @@ export const marketValuations = pgTable('market_valuations', {
|
||||
calculatedAt: timestamp('calculated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const marketValueOverrides = pgTable('market_value_overrides', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid('inventory_item_id').notNull().references(() => inventoryItems.id),
|
||||
valueMinor: integer('value_minor').notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
reason: text('reason').notNull(),
|
||||
createdBy: uuid('created_by').notNull().references(() => users.id),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const inventoryImages = pgTable(
|
||||
'inventory_images',
|
||||
{
|
||||
|
||||
@@ -52,3 +52,15 @@ export function calculateMedianPriceMinor(prices) {
|
||||
|
||||
return sorted[middle];
|
||||
}
|
||||
|
||||
export function identifyOutlierPrices(prices) {
|
||||
if (!Array.isArray(prices) || prices.length < 4) return new Set();
|
||||
const sorted = [...prices].sort((left, right) => left - right);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
const median = sorted.length % 2
|
||||
? sorted[middle]
|
||||
: (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
const lowerFence = median / 3;
|
||||
const upperFence = median * 3;
|
||||
return new Set(prices.filter((price) => price < lowerFence || price > upperFence));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { and, desc, eq, inArray } from 'drizzle-orm';
|
||||
|
||||
import { inventoryItems, marketValuations, pricingObservations, products } from '../../../infrastructure/database/schema.js';
|
||||
import { inventoryItems, marketValueOverrides, marketValuations, pricingObservations, products, securityAuditEvents } from '../../../infrastructure/database/schema.js';
|
||||
import { identifyOutlierPrices } from '../domain/pricing-observation.js';
|
||||
|
||||
export function createPricingRepository(db) {
|
||||
return {
|
||||
@@ -39,5 +40,33 @@ export function createPricingRepository(db) {
|
||||
}).returning();
|
||||
return { status: 'CALCULATED', valuation, observations };
|
||||
},
|
||||
|
||||
async markOutliersForSeller({ inventoryItemId, sellerId }) {
|
||||
const [ownedItem] = await db.select({ id: inventoryItems.id }).from(inventoryItems)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId)));
|
||||
if (!ownedItem) return { status: 'NOT_FOUND' };
|
||||
const observations = await db.select().from(pricingObservations).where(eq(pricingObservations.inventoryItemId, inventoryItemId));
|
||||
const prices = observations.map(({ priceMinor }) => priceMinor);
|
||||
const outlierPrices = identifyOutlierPrices(prices);
|
||||
await db.update(pricingObservations).set({ isOutlier: false, outlierReason: null }).where(eq(pricingObservations.inventoryItemId, inventoryItemId));
|
||||
if (outlierPrices.size) {
|
||||
await db.update(pricingObservations).set({ isOutlier: true, outlierReason: 'Outside the 1.5 IQR range.' })
|
||||
.where(and(eq(pricingObservations.inventoryItemId, inventoryItemId), inArray(pricingObservations.priceMinor, [...outlierPrices])));
|
||||
}
|
||||
return { status: 'UPDATED' };
|
||||
},
|
||||
|
||||
async createOverride({ inventoryItemId, sellerId, actorUserId, valueMinor, currency, reason }) {
|
||||
return db.transaction(async (transaction) => {
|
||||
const [ownedItem] = await transaction.select({ id: inventoryItems.id }).from(inventoryItems)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId)));
|
||||
if (!ownedItem) return { status: 'NOT_FOUND' };
|
||||
const [override] = await transaction.insert(marketValueOverrides).values({ inventoryItemId, valueMinor, currency, reason, createdBy: actorUserId }).returning();
|
||||
await transaction.insert(securityAuditEvents).values({ userId: actorUserId, event: 'MARKET_VALUE_OVERRIDDEN', metadata: { inventoryItemId, overrideId: override.id, reason } });
|
||||
return { status: 'CREATED', override };
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { toAdminMarketValuationDto, toAdminPricingObservationDto } from '@pokemon-inventory/contracts/pricing';
|
||||
import { createPricingObservationSchema } from '@pokemon-inventory/validation/pricing';
|
||||
import { createMarketValueOverrideSchema, createPricingObservationSchema } from '@pokemon-inventory/validation/pricing';
|
||||
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
@@ -40,4 +40,17 @@ export async function registerAdminPricingRoutes(app, options) {
|
||||
const valuation = await pricingService.calculate({ sellerId: request.admin.sellerId, inventoryItemId: request.params.id });
|
||||
return reply.code(200).send({ data: toAdminMarketValuationDto(valuation) });
|
||||
});
|
||||
|
||||
app.post('/inventory/:id/valuation/override', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
||||
const result = createMarketValueOverrideSchema.safeParse(request.body);
|
||||
if (!result.success) return validationError(reply, result);
|
||||
const override = await pricingService.override({
|
||||
...result.data,
|
||||
inventoryItemId: request.params.id,
|
||||
sellerId: request.admin.sellerId,
|
||||
actorUserId: request.admin.userId,
|
||||
});
|
||||
return reply.code(201).send({ data: override });
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,22 @@ export function createPricingService({ pricingRepository }) {
|
||||
return result.observation;
|
||||
},
|
||||
|
||||
async markOutliers({ sellerId, inventoryItemId }) {
|
||||
const result = await pricingRepository.markOutliersForSeller({ sellerId, inventoryItemId });
|
||||
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
||||
return result;
|
||||
},
|
||||
|
||||
async override({ sellerId, actorUserId, inventoryItemId, valueMinor, currency = 'USD', reason }) {
|
||||
if (!Number.isInteger(valueMinor) || valueMinor < 0) throw createApplicationError('INVALID_OVERRIDE_VALUE', 'Override value must be a non-negative integer minor unit.', 400);
|
||||
if (typeof reason !== 'string' || reason.trim().length < 3) throw createApplicationError('OVERRIDE_REASON_REQUIRED', 'An override reason is required.', 400);
|
||||
const result = await pricingRepository.createOverride({ sellerId, actorUserId, inventoryItemId, valueMinor, currency: currency.toUpperCase(), reason: reason.trim() });
|
||||
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
||||
return result.override;
|
||||
},
|
||||
|
||||
async calculate({ sellerId, inventoryItemId }) {
|
||||
await pricingRepository.markOutliersForSeller({ sellerId, inventoryItemId });
|
||||
const result = await pricingRepository.calculateForSeller({ sellerId, inventoryItemId });
|
||||
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
||||
if (result.status === 'NO_DATA') throw createApplicationError('PRICING_DATA_UNAVAILABLE', 'No pricing observations are available.', 404);
|
||||
|
||||
@@ -10,6 +10,9 @@ test('records an owned pricing observation and calculates a valuation', async ()
|
||||
assert.equal(sellerId, 'seller-1');
|
||||
return { status: 'CREATED', observation };
|
||||
},
|
||||
async markOutliersForSeller() {
|
||||
return { status: 'UPDATED' };
|
||||
},
|
||||
async calculateForSeller() {
|
||||
return { status: 'CALCULATED', valuation: { valueMinor: 2500 }, observations: [] };
|
||||
},
|
||||
@@ -23,4 +26,12 @@ test('records an owned pricing observation and calculates a valuation', async ()
|
||||
assert.equal(observation.priceMinor, 2500);
|
||||
const valuation = await service.calculate({ sellerId: 'seller-1', inventoryItemId: 'inventory-1' });
|
||||
assert.equal(valuation.valuation.valueMinor, 2500);
|
||||
});
|
||||
|
||||
test('requires a reason for an audited market-value override', async () => {
|
||||
const service = createPricingService({ pricingRepository: { async createOverride() { throw new Error('should not run'); } } });
|
||||
await assert.rejects(
|
||||
() => service.override({ sellerId: 'seller-1', actorUserId: 'user-1', inventoryItemId: 'inventory-1', valueMinor: 2000, reason: 'x' }),
|
||||
(error) => error.code === 'OVERRIDE_REASON_REQUIRED' && error.statusCode === 400,
|
||||
);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import {
|
||||
calculateMedianPriceMinor,
|
||||
createPricingObservation,
|
||||
identifyOutlierPrices,
|
||||
} from '../../src/modules/pricing/domain/pricing-observation.js';
|
||||
|
||||
test('calculates acquisition cost basis from all cost components', () => {
|
||||
@@ -57,3 +58,8 @@ test('creates pricing observations and calculates a median observed price', () =
|
||||
assert.equal(observation.priceMinor, 2499);
|
||||
assert.equal(calculateMedianPriceMinor([1199, 2499, 2000]), 2000);
|
||||
});
|
||||
|
||||
test('identifies extreme pricing observations without discarding normal values', () => {
|
||||
assert.deepEqual([...identifyOutlierPrices([1000, 1050, 1100, 5000])], [5000]);
|
||||
assert.equal(identifyOutlierPrices([1000, 1050, 1100]).size, 0);
|
||||
});
|
||||
|
||||
@@ -25,4 +25,23 @@ test('accepts a protected pricing observation and valuation request', async (tes
|
||||
assert.equal(valuationResponse.statusCode, 200);
|
||||
assert.equal(valuationResponse.json().data.valueMinor, 2500);
|
||||
assert.equal(csrfChecks, 2);
|
||||
});
|
||||
|
||||
test('accepts a CSRF-protected market-value override', async (testContext) => {
|
||||
let received;
|
||||
const app = buildApp({
|
||||
authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }),
|
||||
authorizeUnsafeRequest: async () => {},
|
||||
pricingService: { async override(input) { received = input; return { id: 'override-1', ...input }; } },
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/inventory/21eb218b-7a95-4f80-a9e3-e306f7ca1f2e/valuation/override',
|
||||
headers: { 'x-csrf-token': 'csrf' },
|
||||
payload: { valueMinor: 2600, reason: 'Verified recent sale' },
|
||||
});
|
||||
assert.equal(response.statusCode, 201);
|
||||
assert.equal(received.actorUserId, 'user-1');
|
||||
assert.equal(response.json().data.valueMinor, 2600);
|
||||
});
|
||||
Reference in New Issue
Block a user