feat: complete inventory image and sales workflows
Quality / test (push) Canceled after 0s

Commit the working vertical slices for inventory image processing and the Sale workflow, including Sale history, audited voiding, multi-item persistence, admin UI, migrations, documentation, and tests.
This commit is contained in:
2026-09-09 00:35:57 -04:00
parent ea145191df
commit 8e5ce80196
32 changed files with 1092 additions and 226 deletions
+42 -10
View File
@@ -22,6 +22,37 @@ function requireNonNegativeMinorUnit(value, fieldName) {
return value;
}
function requireIsoDate(value, fieldName) {
const date = requiredText(value, fieldName);
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
if (!match) {
throw new Error(`${fieldName} must be a valid ISO date.`);
}
const [, year, month, day] = match;
const parsedDate = new Date(`${date}T00:00:00.000Z`);
if (
Number.isNaN(parsedDate.getTime())
|| parsedDate.getUTCFullYear() !== Number(year)
|| parsedDate.getUTCMonth() + 1 !== Number(month)
|| parsedDate.getUTCDate() !== Number(day)
) {
throw new Error(`${fieldName} must be a valid ISO date.`);
}
return date;
}
function requireCurrency(value) {
const currency = requiredText(value, 'Currency').toUpperCase();
if (!/^[A-Z]{3}$/.test(currency)) {
throw new Error('Currency must be a three-letter ISO currency code.');
}
return currency;
}
export function createSale({
sellerId,
saleDate,
@@ -39,12 +70,12 @@ export function createSale({
}) {
return {
sellerId: requiredText(sellerId, 'Seller ID'),
saleDate: requiredText(saleDate, 'Sale date'),
saleDate: requireIsoDate(saleDate, 'Sale date'),
...(typeof saleLocation === 'string' && saleLocation.trim().length > 0
? { saleLocation: saleLocation.trim() }
: {}),
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'),
currency: requiredText(currency, 'Currency'),
currency: requireCurrency(currency),
platformFeesMinor: requireNonNegativeMinorUnit(platformFeesMinor, 'Platform fees'),
paymentProcessingFeesMinor: requireNonNegativeMinorUnit(
paymentProcessingFeesMinor,
@@ -63,17 +94,18 @@ export function createSale({
};
}
export function createSaleItem({
saleId,
inventoryItemId,
soldPriceMinor,
costBasisMinor,
}) {
export function validateSaleItem({ soldPriceMinor, costBasisMinor }) {
return {
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'),
costBasisMinor: requireNonNegativeMinorUnit(costBasisMinor, 'Cost basis'),
};
}
export function createSaleItem({ saleId, inventoryItemId, soldPriceMinor, costBasisMinor }) {
return {
saleId: requiredText(saleId, 'Sale ID'),
inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'),
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'),
costBasisMinor: requireNonNegativeMinorUnit(costBasisMinor, 'Cost basis'),
...validateSaleItem({ soldPriceMinor, costBasisMinor }),
};
}
@@ -0,0 +1,142 @@
import { and, desc, eq, inArray } from 'drizzle-orm';
import {
inventoryItems,
products,
saleItems,
sales,
securityAuditEvents,
} from '../../../infrastructure/database/schema.js';
export function createSaleRepository(db) {
return {
async createForInventoryItem({ sale, inventoryItemId, costBasisMinor }) {
return this.createForInventoryItems({
sale,
items: [{ inventoryItemId, soldPriceMinor: sale.soldPriceMinor, costBasisMinor }],
});
},
async createForInventoryItems({ sale, items }) {
return db.transaction(async (transaction) => {
const ownedInventoryItems = await transaction
.select({ id: inventoryItems.id, status: inventoryItems.status })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(
inArray(inventoryItems.id, items.map((item) => item.inventoryItemId)),
eq(products.sellerId, sale.sellerId),
));
if (ownedInventoryItems.length !== items.length) return { status: 'NOT_FOUND' };
const unavailableItem = ownedInventoryItems.find((item) => item.status !== 'ACTIVE');
if (unavailableItem) {
return { status: 'NOT_ACTIVE', inventoryStatus: unavailableItem.status };
}
const [createdSale] = await transaction.insert(sales).values(sale).returning();
const createdSaleItems = await transaction.insert(saleItems).values(items.map((item) => ({
saleId: createdSale.id,
...item,
}))).returning();
const updatedInventoryItems = await transaction
.update(inventoryItems)
.set({ status: 'SOLD', updatedAt: new Date() })
.where(and(
inArray(inventoryItems.id, items.map((item) => item.inventoryItemId)),
eq(inventoryItems.status, 'ACTIVE'),
))
.returning();
return {
status: 'CREATED',
sale: createdSale,
saleItems: createdSaleItems,
saleItem: createdSaleItems[0],
inventoryItems: updatedInventoryItems,
inventoryItem: updatedInventoryItems[0],
};
});
},
async listBySellerId(sellerId) {
const rows = await db
.select({
sale: sales,
saleItem: saleItems,
inventoryStatus: inventoryItems.status,
productName: products.name,
})
.from(sales)
.innerJoin(saleItems, eq(saleItems.saleId, sales.id))
.innerJoin(inventoryItems, eq(inventoryItems.id, saleItems.inventoryItemId))
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(eq(sales.sellerId, sellerId))
.orderBy(desc(sales.saleDate), desc(sales.createdAt));
const grouped = new Map();
for (const row of rows) {
const current = grouped.get(row.sale.id) ?? {
sale: row.sale,
saleItems: [],
inventoryItems: [],
productNames: [],
};
current.saleItems.push(row.saleItem);
current.inventoryItems.push({ id: row.saleItem.inventoryItemId, status: row.inventoryStatus });
current.productNames.push(row.productName);
grouped.set(row.sale.id, current);
}
return [...grouped.values()].map((sale) => ({
...sale,
saleItem: sale.saleItems[0],
inventoryItem: sale.inventoryItems[0],
productName: sale.productNames.join(', '),
}));
},
async voidForSeller({ saleId, sellerId, actorUserId, reason }) {
return db.transaction(async (transaction) => {
const saleRecords = await transaction
.select({ sale: sales, saleItem: saleItems, inventoryStatus: inventoryItems.status })
.from(sales)
.innerJoin(saleItems, eq(saleItems.saleId, sales.id))
.innerJoin(inventoryItems, eq(inventoryItems.id, saleItems.inventoryItemId))
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(sales.id, saleId), eq(sales.sellerId, sellerId), eq(products.sellerId, sellerId)));
if (!saleRecords.length) return { status: 'NOT_FOUND' };
if (saleRecords[0].sale.status === 'VOIDED') return { status: 'ALREADY_VOIDED' };
const saleRecord = saleRecords[0];
const inventoryItemIds = saleRecords.map(({ saleItem }) => saleItem.inventoryItemId);
const [updatedSale] = await transaction
.update(sales)
.set({ status: 'VOIDED', voidReason: reason, voidedAt: new Date(), updatedAt: new Date() })
.where(and(eq(sales.id, saleId), eq(sales.status, 'COMPLETED')))
.returning();
const [updatedInventoryItem] = await transaction
.update(inventoryItems)
.set({ status: 'ACTIVE', updatedAt: new Date() })
.where(and(
inArray(inventoryItems.id, inventoryItemIds),
eq(inventoryItems.status, 'SOLD'),
))
.returning();
await transaction.insert(securityAuditEvents).values({
userId: actorUserId,
event: 'SALE_VOIDED',
metadata: { saleId, inventoryItemIds, reason },
});
return {
status: 'VOIDED',
sale: updatedSale,
saleItems: saleRecords.map(({ saleItem }) => saleItem),
saleItem: saleRecord.saleItem,
inventoryItem: updatedInventoryItem,
};
});
},
};
}
@@ -0,0 +1,79 @@
import { toAdminSaleDto } from '@pokemon-inventory/contracts/sale';
import { createMultiItemSaleSchema, createSaleSchema, voidSaleSchema } from '@pokemon-inventory/validation/sale';
import { createApplicationError } from '../../../shared/errors/application-error.js';
function validationError(reply, result) {
return reply.code(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Request validation failed.',
details: result.error.issues.map(({ message, path }) => ({ message, path })),
},
});
}
async function defaultAuthenticateAdmin() {
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
}
export async function registerAdminSaleRoutes(app, options) {
const {
authenticateAdmin = defaultAuthenticateAdmin,
authorizeUnsafeRequest,
csrfCookieName = 'pokemon_inventory_csrf',
sessionCookieName = 'pokemon_inventory_session',
saleService,
} = options;
app.addHook('preHandler', async (request) => {
const admin = await authenticateAdmin(request);
if (!admin?.sellerId) {
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
}
request.admin = admin;
});
app.post('/sales', async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers['x-csrf-token'],
);
const schema = Array.isArray(request.body?.items) ? createMultiItemSaleSchema : createSaleSchema;
const result = schema.safeParse(request.body);
if (!result.success) {
return validationError(reply, result);
}
const sale = await saleService.create({
...result.data,
sellerId: request.admin.sellerId,
});
return reply.code(201).send({ data: toAdminSaleDto(sale) });
});
app.get('/sales', async (request) => ({
data: (await saleService.list({ sellerId: request.admin.sellerId })).map(toAdminSaleDto),
}));
app.post('/sales/:id/void', async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers['x-csrf-token'],
);
const result = voidSaleSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result);
const sale = await saleService.void({
saleId: request.params.id,
sellerId: request.admin.sellerId,
actorUserId: request.admin.userId,
reason: result.data.reason,
});
return reply.code(200).send({ data: toAdminSaleDto(sale) });
});
}
@@ -0,0 +1,76 @@
import { calculateNetSaleProceeds, createSale, validateSaleItem } from '../domain/sale.js';
import { createApplicationError } from '../../../shared/errors/application-error.js';
export function createSaleService({ saleRepository }) {
return {
async create({ sellerId, inventoryItemId, costBasisMinor, items, ...input }) {
const saleItems = items ?? [{ inventoryItemId, soldPriceMinor: input.soldPriceMinor, costBasisMinor }];
const totalSoldPriceMinor = items
? saleItems.reduce((total, item) => total + item.soldPriceMinor, 0)
: input.soldPriceMinor;
const sale = createSale({ sellerId, ...input, soldPriceMinor: totalSoldPriceMinor });
const validatedItems = saleItems.map((item) => ({
inventoryItemId: item.inventoryItemId,
soldPriceMinor: validateSaleItem(item).soldPriceMinor,
costBasisMinor: validateSaleItem(item).costBasisMinor,
}));
const result = items
? await saleRepository.createForInventoryItems({ sale, items: validatedItems })
: await saleRepository.createForInventoryItem({
sale,
inventoryItemId,
costBasisMinor: validatedItems[0].costBasisMinor,
});
if (result.status === 'NOT_FOUND') {
throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
}
if (result.status === 'NOT_ACTIVE') {
throw createApplicationError(
'INVENTORY_NOT_AVAILABLE_FOR_SALE',
'Inventory item is not available for sale.',
409,
);
}
return withFinancialSummary(result);
},
async list({ sellerId }) {
const sales = await saleRepository.listBySellerId(sellerId);
return sales.map(withFinancialSummary);
},
async void({ saleId, sellerId, actorUserId, reason }) {
if (typeof reason !== 'string' || reason.trim().length < 3) {
throw createApplicationError('SALE_VOID_REASON_REQUIRED', 'A void reason is required.', 400);
}
const result = await saleRepository.voidForSeller({
saleId,
sellerId,
actorUserId,
reason: reason.trim(),
});
if (result.status === 'NOT_FOUND') {
throw createApplicationError('SALE_NOT_FOUND', 'Sale was not found.', 404);
}
if (result.status === 'ALREADY_VOIDED') {
throw createApplicationError('SALE_ALREADY_VOIDED', 'Sale is already voided.', 409);
}
return withFinancialSummary(result);
},
};
}
function withFinancialSummary(result) {
const netProceedsMinor = calculateNetSaleProceeds(result.sale);
const saleItems = result.saleItems ?? [result.saleItem];
return {
...result,
netProceedsMinor,
profitMinor: netProceedsMinor - saleItems.reduce((total, item) => total + item.costBasisMinor, 0),
};
}