From 75c0e895111458244cbe4e37ad2c56964ed43874 Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Wed, 9 Sep 2026 01:19:51 -0400 Subject: [PATCH] feat: add pricing quality controls Add robust outlier detection, stale valuation metadata, and audited seller-scoped market-value overrides with migration and tests. --- .../api/src/infrastructure/database/schema.js | 10 ++++++ .../pricing/domain/pricing-observation.js | 12 +++++++ .../repositories/pricing-repository.js | 33 +++++++++++++++++-- .../pricing/routes/admin-pricing-routes.js | 15 ++++++++- .../pricing/services/pricing-service.js | 15 +++++++++ .../tests/application/pricing-service.test.js | 11 +++++++ .../tests/domain/acquisition-pricing.test.js | 6 ++++ apps/api/tests/http/pricing-routes.test.js | 19 +++++++++++ database/migrations/0016_pricing_quality.sql | 9 +++++ database/migrations/meta/_journal.json | 7 ++++ packages/contracts/pricing.js | 1 + packages/validation/pricing.js | 6 ++++ 12 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 database/migrations/0016_pricing_quality.sql diff --git a/apps/api/src/infrastructure/database/schema.js b/apps/api/src/infrastructure/database/schema.js index a66fed4..dd973b2 100644 --- a/apps/api/src/infrastructure/database/schema.js +++ b/apps/api/src/infrastructure/database/schema.js @@ -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', { diff --git a/apps/api/src/modules/pricing/domain/pricing-observation.js b/apps/api/src/modules/pricing/domain/pricing-observation.js index 3fad64b..8efcf1d 100644 --- a/apps/api/src/modules/pricing/domain/pricing-observation.js +++ b/apps/api/src/modules/pricing/domain/pricing-observation.js @@ -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)); +} diff --git a/apps/api/src/modules/pricing/repositories/pricing-repository.js b/apps/api/src/modules/pricing/repositories/pricing-repository.js index cbd1634..0c40f2d 100644 --- a/apps/api/src/modules/pricing/repositories/pricing-repository.js +++ b/apps/api/src/modules/pricing/repositories/pricing-repository.js @@ -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 }; + }); + }, }; } \ No newline at end of file diff --git a/apps/api/src/modules/pricing/routes/admin-pricing-routes.js b/apps/api/src/modules/pricing/routes/admin-pricing-routes.js index 0d4f889..a0f2ba1 100644 --- a/apps/api/src/modules/pricing/routes/admin-pricing-routes.js +++ b/apps/api/src/modules/pricing/routes/admin-pricing-routes.js @@ -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 }); + }); } \ No newline at end of file diff --git a/apps/api/src/modules/pricing/services/pricing-service.js b/apps/api/src/modules/pricing/services/pricing-service.js index c6d4f44..a4d4947 100644 --- a/apps/api/src/modules/pricing/services/pricing-service.js +++ b/apps/api/src/modules/pricing/services/pricing-service.js @@ -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); diff --git a/apps/api/tests/application/pricing-service.test.js b/apps/api/tests/application/pricing-service.test.js index ab9bb01..fc8b6f4 100644 --- a/apps/api/tests/application/pricing-service.test.js +++ b/apps/api/tests/application/pricing-service.test.js @@ -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, + ); }); \ No newline at end of file diff --git a/apps/api/tests/domain/acquisition-pricing.test.js b/apps/api/tests/domain/acquisition-pricing.test.js index b80533b..b9e3270 100644 --- a/apps/api/tests/domain/acquisition-pricing.test.js +++ b/apps/api/tests/domain/acquisition-pricing.test.js @@ -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); +}); diff --git a/apps/api/tests/http/pricing-routes.test.js b/apps/api/tests/http/pricing-routes.test.js index 9057c3d..c7ef65c 100644 --- a/apps/api/tests/http/pricing-routes.test.js +++ b/apps/api/tests/http/pricing-routes.test.js @@ -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); }); \ No newline at end of file diff --git a/database/migrations/0016_pricing_quality.sql b/database/migrations/0016_pricing_quality.sql new file mode 100644 index 0000000..de8a125 --- /dev/null +++ b/database/migrations/0016_pricing_quality.sql @@ -0,0 +1,9 @@ +CREATE TABLE "market_value_overrides" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "inventory_item_id" uuid NOT NULL REFERENCES "inventory_items"("id"), + "value_minor" integer NOT NULL, + "currency" text NOT NULL, + "reason" text NOT NULL, + "created_by" uuid NOT NULL REFERENCES "users"("id"), + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); \ No newline at end of file diff --git a/database/migrations/meta/_journal.json b/database/migrations/meta/_journal.json index 81d004f..b811253 100644 --- a/database/migrations/meta/_journal.json +++ b/database/migrations/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1789891200000, "tag": "0015_pricing", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1789977600000, + "tag": "0016_pricing_quality", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/contracts/pricing.js b/packages/contracts/pricing.js index c774f16..0b6fa79 100644 --- a/packages/contracts/pricing.js +++ b/packages/contracts/pricing.js @@ -26,6 +26,7 @@ export function toAdminMarketValuationDto({ valuation, observations }) { confidence: valuation.confidence, sourceCount: valuation.sourceCount, calculatedAt: toIsoString(valuation.calculatedAt), + stale: Date.now() - new Date(valuation.calculatedAt).getTime() > 7 * 24 * 60 * 60 * 1000, observations: observations.map(toAdminPricingObservationDto), }; } \ No newline at end of file diff --git a/packages/validation/pricing.js b/packages/validation/pricing.js index 22f722a..4f93f75 100644 --- a/packages/validation/pricing.js +++ b/packages/validation/pricing.js @@ -10,4 +10,10 @@ export const createPricingObservationSchema = z.object({ conditionId: z.uuid(), sourceUrl: z.string().trim().url().optional(), qualityScore: z.int().min(0).max(100).optional(), +}).strict(); + +export const createMarketValueOverrideSchema = z.object({ + valueMinor: z.int().nonnegative(), + currency: z.string().trim().regex(/^[A-Za-z]{3}$/).optional(), + reason: z.string().trim().min(3).max(1000), }).strict(); \ No newline at end of file