From 4effebd2f8e0b9a4e70fd875c7359ba3a25a10c9 Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Wed, 9 Sep 2026 11:19:27 -0400 Subject: [PATCH] feat: add pricing provider boundary --- .../pricing/providers/pricing-provider.js | 33 +++++++++++++++++++ .../pricing/routes/admin-pricing-routes.js | 13 ++++++++ .../pricing/services/pricing-service.js | 23 ++++++++++++- .../application/pricing-provider.test.js | 22 +++++++++++++ .../tests/application/pricing-service.test.js | 12 +++++++ apps/api/tests/http/pricing-routes.test.js | 26 +++++++++++++++ docs/API.md | 1 + docs/PRICING.md | 6 ++++ 8 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/modules/pricing/providers/pricing-provider.js create mode 100644 apps/api/tests/application/pricing-provider.test.js diff --git a/apps/api/src/modules/pricing/providers/pricing-provider.js b/apps/api/src/modules/pricing/providers/pricing-provider.js new file mode 100644 index 0000000..3931c6f --- /dev/null +++ b/apps/api/src/modules/pricing/providers/pricing-provider.js @@ -0,0 +1,33 @@ +export function createPricingProvider({ id, observe }) { + if (typeof id !== 'string' || id.trim().length === 0) { + throw new Error('Pricing provider id is required.'); + } + + if (typeof observe !== 'function') { + throw new TypeError('Pricing provider observe function is required.'); + } + + return Object.freeze({ + id: id.trim(), + async observe(input) { + const observations = await observe(input); + if (!Array.isArray(observations)) { + throw new Error(`Pricing provider ${id} must return an observation array.`); + } + return observations; + }, + }); +} + +export function createPricingProviderRegistry(providers = []) { + const providerMap = new Map(providers.map((provider) => [provider.id, provider])); + + return { + get(providerId) { + return providerMap.get(providerId) ?? null; + }, + list() { + return [...providerMap.values()]; + }, + }; +} 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 a0f2ba1..993a2c1 100644 --- a/apps/api/src/modules/pricing/routes/admin-pricing-routes.js +++ b/apps/api/src/modules/pricing/routes/admin-pricing-routes.js @@ -53,4 +53,17 @@ export async function registerAdminPricingRoutes(app, options) { }); return reply.code(201).send({ data: override }); }); + + app.post('/inventory/:id/pricing/refresh', async (request, reply) => { + await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); + const { providerId, productId, conditionId } = request.body ?? {}; + const result = await pricingService.refresh({ + sellerId: request.admin.sellerId, + inventoryItemId: request.params.id, + providerId, + productId, + conditionId, + }); + return reply.code(200).send({ data: result }); + }); } \ 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 a4d4947..0bd6186 100644 --- a/apps/api/src/modules/pricing/services/pricing-service.js +++ b/apps/api/src/modules/pricing/services/pricing-service.js @@ -1,7 +1,7 @@ import { createPricingObservation } from '../domain/pricing-observation.js'; import { createApplicationError } from '../../../shared/errors/application-error.js'; -export function createPricingService({ pricingRepository }) { +export function createPricingService({ pricingRepository, pricingProviderRegistry }) { return { async addObservation({ sellerId, ...input }) { const observation = createPricingObservation(input); @@ -31,5 +31,26 @@ export function createPricingService({ pricingRepository }) { if (result.status === 'NO_DATA') throw createApplicationError('PRICING_DATA_UNAVAILABLE', 'No pricing observations are available.', 404); return result; }, + + async refresh({ sellerId, inventoryItemId, providerId, productId, conditionId }) { + const provider = pricingProviderRegistry?.get(providerId); + if (!provider) { + throw createApplicationError('PRICING_PROVIDER_UNAVAILABLE', 'Pricing provider is not configured.', 503); + } + + let providerObservations; + try { + providerObservations = await provider.observe({ inventoryItemId, productId, conditionId }); + } catch (error) { + throw createApplicationError('PRICING_PROVIDER_FAILED', error.message, 502); + } + + const observations = []; + for (const input of providerObservations) { + observations.push(await this.addObservation({ sellerId, ...input, providerId: provider.id, inventoryItemId, productId, conditionId })); + } + + return { observations, valuation: await this.calculate({ sellerId, inventoryItemId }) }; + }, }; } \ No newline at end of file diff --git a/apps/api/tests/application/pricing-provider.test.js b/apps/api/tests/application/pricing-provider.test.js new file mode 100644 index 0000000..c984947 --- /dev/null +++ b/apps/api/tests/application/pricing-provider.test.js @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createPricingProvider, createPricingProviderRegistry } from '../../src/modules/pricing/providers/pricing-provider.js'; + +test('registers pricing providers behind a validated adapter contract', async () => { + const provider = createPricingProvider({ + id: 'example', + async observe() { return [{ priceMinor: 2500, sourceIdentifier: 'example-1' }]; }, + }); + const registry = createPricingProviderRegistry([provider]); + + assert.equal(registry.get('example'), provider); + assert.deepEqual(await registry.get('example').observe({}), [{ priceMinor: 2500, sourceIdentifier: 'example-1' }]); + assert.deepEqual(registry.list(), [provider]); +}); + +test('rejects malformed provider adapters and responses', async () => { + assert.throws(() => createPricingProvider({ id: 'broken' }), /observe function is required/); + const provider = createPricingProvider({ id: 'broken', async observe() { return null; } }); + await assert.rejects(() => provider.observe({}), /must return an observation array/); +}); \ No newline at end of file diff --git a/apps/api/tests/application/pricing-service.test.js b/apps/api/tests/application/pricing-service.test.js index fc8b6f4..924e473 100644 --- a/apps/api/tests/application/pricing-service.test.js +++ b/apps/api/tests/application/pricing-service.test.js @@ -34,4 +34,16 @@ test('requires a reason for an audited market-value override', async () => { () => service.override({ sellerId: 'seller-1', actorUserId: 'user-1', inventoryItemId: 'inventory-1', valueMinor: 2000, reason: 'x' }), (error) => error.code === 'OVERRIDE_REASON_REQUIRED' && error.statusCode === 400, ); +}); + +test('fails clearly when a requested pricing provider is unavailable', async () => { + const service = createPricingService({ + pricingRepository: {}, + pricingProviderRegistry: { get() { return null; } }, + }); + + await assert.rejects( + () => service.refresh({ sellerId: 'seller-1', inventoryItemId: 'inventory-1', providerId: 'missing' }), + (error) => error.code === 'PRICING_PROVIDER_UNAVAILABLE' && error.statusCode === 503, + ); }); \ No newline at end of file diff --git a/apps/api/tests/http/pricing-routes.test.js b/apps/api/tests/http/pricing-routes.test.js index c7ef65c..459a3b8 100644 --- a/apps/api/tests/http/pricing-routes.test.js +++ b/apps/api/tests/http/pricing-routes.test.js @@ -44,4 +44,30 @@ test('accepts a CSRF-protected market-value override', async (testContext) => { assert.equal(response.statusCode, 201); assert.equal(received.actorUserId, 'user-1'); assert.equal(response.json().data.valueMinor, 2600); +}); + +test('returns a clear unavailable response when no provider is configured', async (testContext) => { + const app = buildApp({ + authenticateAdmin: async () => ({ sellerId: 'seller-1' }), + authorizeUnsafeRequest: async () => {}, + pricingService: { + async refresh() { + const error = new Error('Pricing provider is not configured.'); + error.code = 'PRICING_PROVIDER_UNAVAILABLE'; + error.statusCode = 503; + throw error; + }, + }, + }); + testContext.after(() => app.close()); + + const response = await app.inject({ + method: 'POST', + url: '/api/v1/admin/inventory/inventory-1/pricing/refresh', + headers: { 'x-csrf-token': 'csrf' }, + payload: { providerId: 'missing' }, + }); + + assert.equal(response.statusCode, 503); + assert.equal(response.json().error.code, 'PRICING_PROVIDER_UNAVAILABLE'); }); \ No newline at end of file diff --git a/docs/API.md b/docs/API.md index ada088a..3b0a2da 100644 --- a/docs/API.md +++ b/docs/API.md @@ -89,6 +89,7 @@ POST /api/v1/admin/sales/:id/void POST /api/v1/admin/pricing/observations POST /api/v1/admin/inventory/:id/valuation POST /api/v1/admin/inventory/:id/valuation/override +POST /api/v1/admin/inventory/:id/pricing/refresh POST /api/v1/admin/bulk GET /api/v1/admin/dashboard diff --git a/docs/PRICING.md b/docs/PRICING.md index 294ba6b..244746f 100644 --- a/docs/PRICING.md +++ b/docs/PRICING.md @@ -22,6 +22,12 @@ A provider returns normalized observations containing: Provider adapters own provider-specific API behavior and normalization. +The application uses a validated provider registry. A refresh request selects a +registered provider, normalizes its observation array through the existing +pricing service, and recalculates the item valuation. Missing providers return +an explicit unavailable response; the application never fabricates observations +or values. + Use officially supported APIs or permitted sources. Do not invent APIs or bypass restrictions. ## Provider Product Mapping