feat: add admin pricing workflow
Quality / test (push) Canceled after 0s

Add the admin pricing observation and valuation form, secure valuation writes with CSRF, and extend authenticated browser coverage.
This commit is contained in:
2026-09-09 01:15:30 -04:00
parent 721b590a89
commit ba88814af0
5 changed files with 71 additions and 1 deletions
@@ -36,6 +36,7 @@ export async function registerAdminPricingRoutes(app, options) {
}); });
app.post('/inventory/:id/valuation', async (request, reply) => { app.post('/inventory/:id/valuation', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
const valuation = await pricingService.calculate({ sellerId: request.admin.sellerId, inventoryItemId: request.params.id }); const valuation = await pricingService.calculate({ sellerId: request.admin.sellerId, inventoryItemId: request.params.id });
return reply.code(200).send({ data: toAdminMarketValuationDto(valuation) }); return reply.code(200).send({ data: toAdminMarketValuationDto(valuation) });
}); });
+1 -1
View File
@@ -24,5 +24,5 @@ test('accepts a protected pricing observation and valuation request', async (tes
assert.equal(observationResponse.statusCode, 201); assert.equal(observationResponse.statusCode, 201);
assert.equal(valuationResponse.statusCode, 200); assert.equal(valuationResponse.statusCode, 200);
assert.equal(valuationResponse.json().data.valueMinor, 2500); assert.equal(valuationResponse.json().data.valueMinor, 2500);
assert.equal(csrfChecks, 1); assert.equal(csrfChecks, 2);
}); });
+37
View File
@@ -9,6 +9,7 @@ import { createAdminListing, listAdminListings, markListingPending, publishAdmin
import { createAdminSale, listAdminSales, voidAdminSale } from '@/services/admin-sale-service.js'; import { createAdminSale, listAdminSales, voidAdminSale } from '@/services/admin-sale-service.js';
import { createAdminAcquisition, listAdminAcquisitions } from '@/services/admin-acquisition-service.js'; import { createAdminAcquisition, listAdminAcquisitions } from '@/services/admin-acquisition-service.js';
import { getAdminDashboard } from '@/services/admin-dashboard-service.js'; import { getAdminDashboard } from '@/services/admin-dashboard-service.js';
import { addAdminPricingObservation, calculateAdminValuation } from '@/services/admin-pricing-service.js';
const { principal } = useAuth(); const { principal } = useAuth();
const products = ref([]); const products = ref([]);
@@ -17,6 +18,7 @@ const listings = ref([]);
const sales = ref([]); const sales = ref([]);
const acquisitions = ref([]); const acquisitions = ref([]);
const dashboard = ref(null); const dashboard = ref(null);
const valuations = ref({});
const categories = ref([]); const categories = ref([]);
const conditions = ref([]); const conditions = ref([]);
const isLoading = ref(true); const isLoading = ref(true);
@@ -38,6 +40,10 @@ const acquisitionCost = ref('');
const acquisitionShipping = ref(''); const acquisitionShipping = ref('');
const acquisitionTax = ref(''); const acquisitionTax = ref('');
const acquisitionDate = ref(new Date().toISOString().slice(0, 10)); const acquisitionDate = ref(new Date().toISOString().slice(0, 10));
const pricingInventoryId = ref('');
const pricingProvider = ref('manual');
const pricingSource = ref('');
const pricingPrice = ref('');
const voidReasons = ref({}); const voidReasons = ref({});
function csrfToken() { function csrfToken() {
@@ -132,6 +138,19 @@ async function recordAcquisition() {
finally { isSaving.value = false; } finally { isSaving.value = false; }
} }
async function calculatePricing() {
isSaving.value = true;
try {
const item = inventory.value.find(({ id }) => id === pricingInventoryId.value);
await addAdminPricingObservation({ productId: item.productId, inventoryItemId: item.id, providerId: pricingProvider.value, priceMinor: Number(pricingPrice.value), sourceIdentifier: pricingSource.value, conditionId: item.conditionId }, csrfToken());
const valuation = await calculateAdminValuation(item.id, csrfToken());
valuations.value = { ...valuations.value, [item.id]: valuation };
pricingSource.value = '';
pricingPrice.value = '';
} catch (requestError) { error.value = requestError; }
finally { isSaving.value = false; }
}
async function voidSale(saleId) { async function voidSale(saleId) {
isSaving.value = true; isSaving.value = true;
try { try {
@@ -241,6 +260,21 @@ onMounted(load);
<input id="acquisition-tax" v-model="acquisitionTax" class="fixture-input" type="number" min="0" /> <input id="acquisition-tax" v-model="acquisitionTax" class="fixture-input" type="number" min="0" />
<button class="button button--primary" type="submit" :disabled="isSaving">Record acquisition</button> <button class="button button--primary" type="submit" :disabled="isSaving">Record acquisition</button>
</form> </form>
<form class="showcase-panel" @submit.prevent="calculatePricing">
<h2>Calculate market value</h2>
<label class="fixture-label" for="pricing-inventory">Inventory copy</label>
<select id="pricing-inventory" v-model="pricingInventoryId" class="fixture-input" required>
<option value="" disabled>Select a copy</option>
<option v-for="item in inventory" :key="item.id" :value="item.id">{{ item.id }}</option>
</select>
<label class="fixture-label" for="pricing-provider">Provider</label>
<input id="pricing-provider" v-model="pricingProvider" class="fixture-input" required />
<label class="fixture-label" for="pricing-source">Source identifier</label>
<input id="pricing-source" v-model="pricingSource" class="fixture-input" required />
<label class="fixture-label" for="pricing-price">Observed price (cents)</label>
<input id="pricing-price" v-model="pricingPrice" class="fixture-input" type="number" min="1" required />
<button class="button button--primary" type="submit" :disabled="isSaving">Add observation and calculate</button>
</form>
</div> </div>
<section class="admin-list" aria-labelledby="products-title"> <section class="admin-list" aria-labelledby="products-title">
@@ -252,6 +286,9 @@ onMounted(load);
<h2 id="inventory-title">Physical inventory</h2> <h2 id="inventory-title">Physical inventory</h2>
<p v-if="!inventory.length && !isLoading" class="state state--empty">No physical copies yet.</p> <p v-if="!inventory.length && !isLoading" class="state state--empty">No physical copies yet.</p>
<ul v-else><li v-for="item in inventory" :key="item.id" :data-product-id="item.productId"><span>{{ item.id }}</span> · {{ item.status }} <RouterLink :to="{ name: 'admin-image-manager', params: { id: item.id } }">Manage images</RouterLink></li></ul> <ul v-else><li v-for="item in inventory" :key="item.id" :data-product-id="item.productId"><span>{{ item.id }}</span> · {{ item.status }} <RouterLink :to="{ name: 'admin-image-manager', params: { id: item.id } }">Manage images</RouterLink></li></ul>
<ul v-if="Object.keys(valuations).length" aria-label="Market valuations">
<li v-for="(valuation, inventoryItemId) in valuations" :key="inventoryItemId">{{ inventoryItemId }} · Market value ${{ (valuation.valueMinor / 100).toFixed(2) }} · {{ valuation.confidence }} confidence · {{ valuation.sourceCount }} source{{ valuation.sourceCount === 1 ? '' : 's' }}</li>
</ul>
</section> </section>
<section class="admin-list" aria-labelledby="listings-title"> <section class="admin-list" aria-labelledby="listings-title">
<h2 id="listings-title">Listings</h2> <h2 id="listings-title">Listings</h2>
@@ -0,0 +1,27 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
async function request(path, options = {}) {
const response = await fetch(`${apiBaseUrl}${path}`, {
credentials: 'include',
...options,
headers: { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers },
});
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Pricing request failed.');
return payload?.data;
}
export function addAdminPricingObservation(input, csrfToken) {
return request('/admin/pricing/observations', {
method: 'POST',
headers: { 'x-csrf-token': csrfToken },
body: JSON.stringify(input),
});
}
export function calculateAdminValuation(inventoryItemId, csrfToken) {
return request(`/admin/inventory/${encodeURIComponent(inventoryItemId)}/valuation`, {
method: 'POST',
headers: { 'x-csrf-token': csrfToken },
});
}
@@ -43,6 +43,11 @@ test('signs in and publishes a listing after adding an inventory image', async (
await page.getByLabel('Tax (cents)').fill('50'); await page.getByLabel('Tax (cents)').fill('50');
await page.getByRole('button', { name: 'Record acquisition' }).click(); await page.getByRole('button', { name: 'Record acquisition' }).click();
await expect(page.locator('section[aria-labelledby="acquisitions-title"] li').first()).toContainText('Basis $11.50'); await expect(page.locator('section[aria-labelledby="acquisitions-title"] li').first()).toContainText('Basis $11.50');
await page.locator('#pricing-inventory').selectOption(createdInventoryId);
await page.getByLabel('Source identifier').fill('browser-price-1');
await page.getByLabel('Observed price (cents)').fill('2500');
await page.getByRole('button', { name: 'Add observation and calculate' }).click();
await expect(page.getByRole('list', { name: 'Market valuations' })).toContainText('Market value $25.00');
await inventoryLink.click(); await inventoryLink.click();
await expect(page).toHaveURL(/\/admin\/inventory\/[^/]+\/images$/); await expect(page).toHaveURL(/\/admin\/inventory\/[^/]+\/images$/);
await expect(page.getByRole('heading', { name: 'Manage images' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Manage images' })).toBeVisible();