This commit is contained in:
@@ -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()];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -53,4 +53,17 @@ export async function registerAdminPricingRoutes(app, options) {
|
|||||||
});
|
});
|
||||||
return reply.code(201).send({ data: override });
|
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 });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createPricingObservation } from '../domain/pricing-observation.js';
|
import { createPricingObservation } from '../domain/pricing-observation.js';
|
||||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||||
|
|
||||||
export function createPricingService({ pricingRepository }) {
|
export function createPricingService({ pricingRepository, pricingProviderRegistry }) {
|
||||||
return {
|
return {
|
||||||
async addObservation({ sellerId, ...input }) {
|
async addObservation({ sellerId, ...input }) {
|
||||||
const observation = createPricingObservation(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);
|
if (result.status === 'NO_DATA') throw createApplicationError('PRICING_DATA_UNAVAILABLE', 'No pricing observations are available.', 404);
|
||||||
return result;
|
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 }) };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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' }),
|
() => service.override({ sellerId: 'seller-1', actorUserId: 'user-1', inventoryItemId: 'inventory-1', valueMinor: 2000, reason: 'x' }),
|
||||||
(error) => error.code === 'OVERRIDE_REASON_REQUIRED' && error.statusCode === 400,
|
(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,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
@@ -44,4 +44,30 @@ test('accepts a CSRF-protected market-value override', async (testContext) => {
|
|||||||
assert.equal(response.statusCode, 201);
|
assert.equal(response.statusCode, 201);
|
||||||
assert.equal(received.actorUserId, 'user-1');
|
assert.equal(received.actorUserId, 'user-1');
|
||||||
assert.equal(response.json().data.valueMinor, 2600);
|
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');
|
||||||
});
|
});
|
||||||
@@ -89,6 +89,7 @@ POST /api/v1/admin/sales/:id/void
|
|||||||
POST /api/v1/admin/pricing/observations
|
POST /api/v1/admin/pricing/observations
|
||||||
POST /api/v1/admin/inventory/:id/valuation
|
POST /api/v1/admin/inventory/:id/valuation
|
||||||
POST /api/v1/admin/inventory/:id/valuation/override
|
POST /api/v1/admin/inventory/:id/valuation/override
|
||||||
|
POST /api/v1/admin/inventory/:id/pricing/refresh
|
||||||
POST /api/v1/admin/bulk
|
POST /api/v1/admin/bulk
|
||||||
|
|
||||||
GET /api/v1/admin/dashboard
|
GET /api/v1/admin/dashboard
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ A provider returns normalized observations containing:
|
|||||||
|
|
||||||
Provider adapters own provider-specific API behavior and normalization.
|
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.
|
Use officially supported APIs or permitted sources. Do not invent APIs or bypass restrictions.
|
||||||
|
|
||||||
## Provider Product Mapping
|
## Provider Product Mapping
|
||||||
|
|||||||
Reference in New Issue
Block a user