diff --git a/backend/src/intake/priceSource.ts b/backend/src/intake/priceSource.ts new file mode 100644 index 0000000..ff87238 --- /dev/null +++ b/backend/src/intake/priceSource.ts @@ -0,0 +1,34 @@ +/** + * Where an item's price came from, and when that changes. + * + * This is the protection that used to live in the schema. Items are priced on + * arrival — the model's suggestion, or the 80.00 default — so nothing stops a + * number nobody chose from reaching the storefront except the review queue + * showing that it was never chosen. + * + * Pure and separately tested because the failure is silent. An item that sells + * at a default price looks exactly like one that sells at a chosen price; + * 80.00 was picked precisely because it reads as a decision rather than as an + * obvious sentinel the way 0.00 would. + */ +export type PriceSource = 'default' | 'ai' | 'admin'; + +/** + * Editing the number is the admin taking responsibility for it, and it is the + * only thing that can. Publishing without touching the field deliberately does + * NOT confirm it — that would turn "I did not look at this" into "I approved + * this", which is the exact misrecording the review queue exists to prevent. + */ +export function nextPriceSource( + current: PriceSource, + submittedCents: number, + storedCents: number +): PriceSource { + if (current === 'admin') return 'admin'; + return submittedCents === storedCents ? current : 'admin'; +} + +/** Anything a person did not choose, which the screen marks visibly. */ +export function isUnconfirmed(source: PriceSource): boolean { + return source !== 'admin'; +} diff --git a/backend/tests/unit/priceSource.test.ts b/backend/tests/unit/priceSource.test.ts new file mode 100644 index 0000000..d967d35 --- /dev/null +++ b/backend/tests/unit/priceSource.test.ts @@ -0,0 +1,32 @@ +import { nextPriceSource, isUnconfirmed } from '../../src/intake/priceSource'; + +describe('nextPriceSource', () => { + // Touching the number is the admin taking responsibility for it. That is the + // only event that can confirm a price. + it('becomes admin when the number changes', () => { + expect(nextPriceSource('default', 9500, 8000)).toBe('admin'); + expect(nextPriceSource('ai', 4000, 4500)).toBe('admin'); + }); + + // Publishing without touching the field must NOT silently confirm it. That + // is the whole failure this screen exists to prevent: an item selling at a + // number nobody chose, with nothing recording that. + it('leaves an untouched price unconfirmed', () => { + expect(nextPriceSource('default', 8000, 8000)).toBe('default'); + expect(nextPriceSource('ai', 4500, 4500)).toBe('ai'); + }); + + // Already confirmed stays confirmed, including when re-submitted unchanged. + it('keeps admin once set', () => { + expect(nextPriceSource('admin', 9500, 9500)).toBe('admin'); + expect(nextPriceSource('admin', 7000, 9500)).toBe('admin'); + }); +}); + +describe('isUnconfirmed', () => { + it('treats anything but admin as unconfirmed', () => { + expect(isUnconfirmed('default')).toBe(true); + expect(isUnconfirmed('ai')).toBe(true); + expect(isUnconfirmed('admin')).toBe(false); + }); +});