diff --git a/apps/worker/src/pricing-refresh-worker.js b/apps/worker/src/pricing-refresh-worker.js new file mode 100644 index 0000000..9a81626 --- /dev/null +++ b/apps/worker/src/pricing-refresh-worker.js @@ -0,0 +1,53 @@ +export async function runPricingRefreshJob({ job, providerRegistry, pricingService }) { + if (!job || job.type !== 'PRICING_REFRESH') { + throw new Error('Unsupported pricing refresh job.'); + } + + const provider = providerRegistry?.get(job.providerId); + if (!provider) { + return { + ...job, + status: 'FAILED', + error: 'Pricing provider is not configured.', + failedAt: new Date(), + }; + } + + try { + const observations = await provider.observe({ + inventoryItemId: job.inventoryItemId, + productId: job.productId, + conditionId: job.conditionId, + }); + const savedObservations = []; + for (const observation of observations) { + savedObservations.push(await pricingService.addObservation({ + sellerId: job.sellerId, + ...observation, + providerId: provider.id, + inventoryItemId: job.inventoryItemId, + productId: job.productId, + conditionId: job.conditionId, + })); + } + const valuation = await pricingService.calculate({ + sellerId: job.sellerId, + inventoryItemId: job.inventoryItemId, + }); + + return { + ...job, + status: 'COMPLETED', + observations: savedObservations, + valuation, + completedAt: new Date(), + }; + } catch (error) { + return { + ...job, + status: 'FAILED', + error: error instanceof Error ? error.message : 'Pricing refresh failed.', + failedAt: new Date(), + }; + } +} diff --git a/apps/worker/tests/pricing-refresh-worker.test.js b/apps/worker/tests/pricing-refresh-worker.test.js new file mode 100644 index 0000000..8afee7a --- /dev/null +++ b/apps/worker/tests/pricing-refresh-worker.test.js @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createPricingProvider, createPricingProviderRegistry } from '../../api/src/modules/pricing/providers/pricing-provider.js'; +import { runPricingRefreshJob } from '../src/pricing-refresh-worker.js'; + +test('completes a pricing refresh through the provider and pricing service boundaries', async () => { + const providerRegistry = createPricingProviderRegistry([ + createPricingProvider({ + id: 'fixture-provider', + async observe() { + return [{ priceMinor: 2500, sourceIdentifier: 'fixture-1' }]; + }, + }), + ]); + const saved = []; + const result = await runPricingRefreshJob({ + job: { + id: 'job-1', + type: 'PRICING_REFRESH', + providerId: 'fixture-provider', + sellerId: 'seller-1', + productId: 'product-1', + inventoryItemId: 'inventory-1', + conditionId: 'condition-1', + }, + providerRegistry, + pricingService: { + async addObservation(input) { saved.push(input); return input; }, + async calculate() { return { valuation: { valueMinor: 2500 } }; }, + }, + }); + + assert.equal(result.status, 'COMPLETED'); + assert.equal(result.valuation.valuation.valueMinor, 2500); + assert.equal(saved[0].providerId, 'fixture-provider'); +}); + +test('fails a refresh clearly when a provider is unavailable', async () => { + const result = await runPricingRefreshJob({ + job: { id: 'job-2', type: 'PRICING_REFRESH', providerId: 'missing' }, + providerRegistry: createPricingProviderRegistry(), + pricingService: {}, + }); + + assert.equal(result.status, 'FAILED'); + assert.match(result.error, /not configured/); +}); diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index f8146e6..d02c95c 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -149,6 +149,7 @@ Work through one bounded vertical slice at a time. The linked documentation rema - No initial User/Seller is provisioned in the development database yet. Run `npm run provision:admin` after setting its required local `.env` variables. - Listings are partially implemented: draft creation, seller-scoped reads, publication gating, lifecycle writes, archive behavior, sold/archived inventory checks, and item-owned image metadata are complete; storage-backed processing remains. - Concrete provider adapters, asynchronous pricing refresh transport, richer exports/settings, full integration CI services, and deployment/backup automation remain incomplete to varying degrees. +- The pricing refresh worker boundary now has explicit completed/failed outcomes and routes provider observations through the pricing service; persistent refresh-job storage and a configured external provider remain deployment work. - `npm install` currently reports dependency audit findings. Review and remediate them before production deployment; do not use `npm audit fix --force` without reviewing its changes. ## Local Commands diff --git a/docs/PRICING.md b/docs/PRICING.md index 244746f..c35be2b 100644 --- a/docs/PRICING.md +++ b/docs/PRICING.md @@ -104,6 +104,12 @@ Refresh: Bulk refresh runs through BullMQ in the worker. +The worker-side refresh boundary is implemented with explicit `COMPLETED` and +`FAILED` outcomes. It resolves a registered provider, persists normalized +observations through the pricing service, and recalculates the valuation. A +provider that is missing or fails does not produce a fabricated value. Durable +refresh-job storage and external provider configuration remain deployment work. + Provider failure should not invalidate other valid observations. The failed provider is recorded for admin diagnostics. ## Overrides