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:
@@ -14,6 +14,7 @@ import { registerAdminProductRoutes } from '../modules/products/routes/admin-pro
|
||||
import { registerPublicProductRoutes } from '../modules/products/routes/public-product-routes.js';
|
||||
import { registerPublicSearchRoutes } from '../modules/search/routes/public-search-routes.js';
|
||||
import { registerPublicSellerRoutes } from '../modules/sellers/routes/public-seller-routes.js';
|
||||
import { registerAdminSaleRoutes } from '../modules/sales/routes/admin-sale-routes.js';
|
||||
|
||||
export function buildApp(dependencies) {
|
||||
const app = Fastify();
|
||||
@@ -61,6 +62,10 @@ export function buildApp(dependencies) {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
});
|
||||
app.register(registerAdminSaleRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
});
|
||||
app.register(registerPublicSearchRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1',
|
||||
|
||||
@@ -5,6 +5,7 @@ dotenv.config({ path: fileURLToPath(new URL('../../../../.env', import.meta.url)
|
||||
|
||||
import { buildApp } from './build-app.js';
|
||||
import { createDatabase } from '../infrastructure/database/client.js';
|
||||
import { resolveProjectImageStorageRoot } from '../infrastructure/images/local-image-storage.js';
|
||||
import { createPasswordResetNotifier } from '../infrastructure/email/password-reset-notifier.js';
|
||||
import { createRedisConnection } from '../infrastructure/redis/client.js';
|
||||
import { createAuthenticationService } from '../modules/auth/services/authentication-service.js';
|
||||
@@ -36,6 +37,8 @@ import { createPublicProductService } from '../modules/products/services/public-
|
||||
import { createSearchService } from '../modules/search/services/search-service.js';
|
||||
import { createSellerRepository } from '../modules/sellers/repositories/seller-repository.js';
|
||||
import { createPublicSellerService } from '../modules/sellers/services/public-seller-service.js';
|
||||
import { createSaleRepository } from '../modules/sales/repositories/sale-repository.js';
|
||||
import { createSaleService } from '../modules/sales/services/sale-service.js';
|
||||
|
||||
function parsePort(value) {
|
||||
const port = Number.parseInt(value ?? '3000', 10);
|
||||
@@ -48,6 +51,7 @@ function parsePort(value) {
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const imageStorageRoot = resolveProjectImageStorageRoot(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage', import.meta.url);
|
||||
const database = createDatabase();
|
||||
const redis = await createRedisConnection();
|
||||
const productRepository = createProductRepository(database.db);
|
||||
@@ -58,6 +62,7 @@ async function startServer() {
|
||||
const listingRepository = createListingRepository(database.db);
|
||||
const sellerRepository = createSellerRepository(database.db);
|
||||
const conditionRepository = createConditionRepository(database.db);
|
||||
const saleRepository = createSaleRepository(database.db);
|
||||
const userRepository = createUserRepository(database.db);
|
||||
const sessionRepository = createSessionRepository(database.db);
|
||||
const passwordResetRepository = createPasswordResetRepository(database.db);
|
||||
@@ -71,7 +76,7 @@ async function startServer() {
|
||||
});
|
||||
const passwordResetNotifier = createPasswordResetNotifier();
|
||||
const publicImageService = createPublicImageService({
|
||||
imageStorage: createLocalImageStorage(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage'),
|
||||
imageStorage: createLocalImageStorage(imageStorageRoot),
|
||||
inventoryImageRepository,
|
||||
});
|
||||
const authenticationService = createAuthenticationService({
|
||||
@@ -108,11 +113,12 @@ async function startServer() {
|
||||
publicImageService,
|
||||
publicInventoryService: createPublicInventoryService({ inventoryItemRepository, publicImageService }),
|
||||
inventoryImageService: createInventoryImageService({
|
||||
imageStorage: createLocalImageStorage(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage'),
|
||||
imageStorage: createLocalImageStorage(imageStorageRoot),
|
||||
imageDerivativeJobRepository,
|
||||
inventoryImageRepository,
|
||||
}),
|
||||
listingService: createListingService({ listingRepository, inventoryImageRepository }),
|
||||
saleService: createSaleService({ saleRepository }),
|
||||
publicListingService: createPublicListingService({ listingRepository, publicImageService }),
|
||||
productService: createProductService({ productRepository }),
|
||||
publicProductService: createPublicProductService({ productRepository }),
|
||||
|
||||
@@ -297,4 +297,46 @@ export const listings = pgTable('listings', {
|
||||
}, (table) => [
|
||||
index('listings_inventory_item_id_index').on(table.inventoryItemId),
|
||||
index('listings_status_index').on(table.status),
|
||||
]);
|
||||
]);
|
||||
|
||||
export const sales = pgTable('sales', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
sellerId: uuid('seller_id')
|
||||
.notNull()
|
||||
.references(() => sellers.id),
|
||||
status: text('status').default('COMPLETED').notNull(),
|
||||
saleDate: text('sale_date').notNull(),
|
||||
saleLocation: text('sale_location'),
|
||||
soldPriceMinor: integer('sold_price_minor').notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
platformFeesMinor: integer('platform_fees_minor').default(0).notNull(),
|
||||
paymentProcessingFeesMinor: integer('payment_processing_fees_minor').default(0).notNull(),
|
||||
shippingCostMinor: integer('shipping_cost_minor').default(0).notNull(),
|
||||
taxesMinor: integer('taxes_minor').default(0).notNull(),
|
||||
otherFeesMinor: integer('other_fees_minor').default(0).notNull(),
|
||||
buyerName: text('buyer_name'),
|
||||
buyerContact: text('buyer_contact'),
|
||||
notes: text('notes'),
|
||||
voidReason: text('void_reason'),
|
||||
voidedAt: timestamp('voided_at', { withTimezone: true }),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const saleItems = pgTable(
|
||||
'sale_items',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
saleId: uuid('sale_id')
|
||||
.notNull()
|
||||
.references(() => sales.id),
|
||||
inventoryItemId: uuid('inventory_item_id')
|
||||
.notNull()
|
||||
.references(() => inventoryItems.id),
|
||||
soldPriceMinor: integer('sold_price_minor').notNull(),
|
||||
costBasisMinor: integer('cost_basis_minor').notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('sale_items_inventory_item_id_unique').on(table.inventoryItemId),
|
||||
index('sale_items_sale_id_index').on(table.saleId),
|
||||
],
|
||||
);
|
||||
@@ -1,8 +1,37 @@
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { createImageStorageContract } from './image-storage.js';
|
||||
|
||||
function findProjectRoot(startDir) {
|
||||
let currentDir = startDir;
|
||||
|
||||
while (currentDir && currentDir !== path.dirname(currentDir)) {
|
||||
if (existsSync(path.join(currentDir, 'package.json')) && existsSync(path.join(currentDir, 'AGENTS.md'))) {
|
||||
return currentDir;
|
||||
}
|
||||
currentDir = path.dirname(currentDir);
|
||||
}
|
||||
|
||||
return startDir;
|
||||
}
|
||||
|
||||
export function resolveProjectImageStorageRoot(rootDirectory = '.local/image-storage', sourceUrl = import.meta.url) {
|
||||
const projectRoot = findProjectRoot(path.dirname(fileURLToPath(sourceUrl)));
|
||||
|
||||
if (typeof rootDirectory !== 'string' || rootDirectory.length === 0) {
|
||||
return path.join(projectRoot, '.local/image-storage');
|
||||
}
|
||||
|
||||
if (path.isAbsolute(rootDirectory)) {
|
||||
return rootDirectory;
|
||||
}
|
||||
|
||||
return path.resolve(projectRoot, rootDirectory);
|
||||
}
|
||||
|
||||
function safePath(rootDirectory, key) {
|
||||
if (typeof key !== 'string' || key.length === 0 || path.isAbsolute(key)) {
|
||||
throw new Error('Image storage key must be a relative non-empty path.');
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createImageStorageContract } from '../../src/infrastructure/images/image-storage.js';
|
||||
import { createLocalImageStorage } from '../../src/infrastructure/images/local-image-storage.js';
|
||||
import { createLocalImageStorage, resolveProjectImageStorageRoot } from '../../src/infrastructure/images/local-image-storage.js';
|
||||
|
||||
test('local image storage round-trips and deletes binary data', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'pokemon-images-'));
|
||||
@@ -40,6 +41,16 @@ test('local image storage rejects traversal and duplicate writes', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('relative storage roots resolve from the project root regardless of the process cwd', () => {
|
||||
const apiSource = new URL('../../src/app/server.js', import.meta.url);
|
||||
const workerSource = new URL('../../../worker/src/worker-runtime.js', import.meta.url);
|
||||
const expectedRoot = fileURLToPath(new URL('../../../../', import.meta.url));
|
||||
|
||||
assert.equal(resolveProjectImageStorageRoot('.local/image-storage', apiSource), path.join(expectedRoot, '.local/image-storage'));
|
||||
assert.equal(resolveProjectImageStorageRoot('.local/image-storage', workerSource), path.join(expectedRoot, '.local/image-storage'));
|
||||
assert.equal(resolveProjectImageStorageRoot('/tmp/custom-image-storage', apiSource), '/tmp/custom-image-storage');
|
||||
});
|
||||
|
||||
test('image storage contracts reject incomplete adapters', () => {
|
||||
assert.throws(
|
||||
() => createImageStorageContract({ put() {} }),
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createSaleService } from '../../src/modules/sales/services/sale-service.js';
|
||||
|
||||
function validInput(overrides = {}) {
|
||||
return {
|
||||
sellerId: 'seller-1',
|
||||
inventoryItemId: 'inventory-1',
|
||||
saleDate: '2026-09-09',
|
||||
soldPriceMinor: 2499,
|
||||
costBasisMinor: 1200,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('creates a sale through the repository with validated money values', async () => {
|
||||
let received;
|
||||
const service = createSaleService({
|
||||
saleRepository: {
|
||||
async createForInventoryItem(input) {
|
||||
received = input;
|
||||
return {
|
||||
status: 'CREATED',
|
||||
sale: {
|
||||
id: 'sale-1',
|
||||
soldPriceMinor: 2499,
|
||||
platformFeesMinor: 100,
|
||||
paymentProcessingFeesMinor: 0,
|
||||
shippingCostMinor: 0,
|
||||
taxesMinor: 0,
|
||||
otherFeesMinor: 0,
|
||||
},
|
||||
saleItem: { costBasisMinor: 1200 },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await service.create(validInput()), {
|
||||
status: 'CREATED',
|
||||
sale: {
|
||||
id: 'sale-1',
|
||||
soldPriceMinor: 2499,
|
||||
platformFeesMinor: 100,
|
||||
paymentProcessingFeesMinor: 0,
|
||||
shippingCostMinor: 0,
|
||||
taxesMinor: 0,
|
||||
otherFeesMinor: 0,
|
||||
},
|
||||
saleItem: { costBasisMinor: 1200 },
|
||||
netProceedsMinor: 2399,
|
||||
profitMinor: 1199,
|
||||
});
|
||||
assert.equal(received.sale.sellerId, 'seller-1');
|
||||
assert.equal(received.sale.soldPriceMinor, 2499);
|
||||
assert.equal(received.costBasisMinor, 1200);
|
||||
});
|
||||
|
||||
test('lists seller sales with derived net proceeds and profit', async () => {
|
||||
const service = createSaleService({
|
||||
saleRepository: {
|
||||
async listBySellerId(sellerId) {
|
||||
assert.equal(sellerId, 'seller-1');
|
||||
return [{
|
||||
sale: { soldPriceMinor: 3000, shippingCostMinor: 200 },
|
||||
saleItem: { costBasisMinor: 1500 },
|
||||
inventoryItem: { id: 'inventory-1', status: 'SOLD' },
|
||||
}];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [sale] = await service.list({ sellerId: 'seller-1' });
|
||||
assert.equal(sale.netProceedsMinor, 2800);
|
||||
assert.equal(sale.profitMinor, 1300);
|
||||
});
|
||||
|
||||
test('voids an owned sale with a required reason', async () => {
|
||||
let received;
|
||||
const service = createSaleService({
|
||||
saleRepository: {
|
||||
async voidForSeller(input) {
|
||||
received = input;
|
||||
return {
|
||||
status: 'VOIDED',
|
||||
sale: { id: 'sale-1', soldPriceMinor: 3000 },
|
||||
saleItem: { costBasisMinor: 1500 },
|
||||
inventoryItem: { id: 'inventory-1', status: 'ACTIVE' },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.void({
|
||||
saleId: 'sale-1',
|
||||
sellerId: 'seller-1',
|
||||
actorUserId: 'user-1',
|
||||
reason: ' Duplicate sale ',
|
||||
});
|
||||
|
||||
assert.equal(received.reason, 'Duplicate sale');
|
||||
assert.equal(result.inventoryItem.status, 'ACTIVE');
|
||||
await assert.rejects(
|
||||
() => service.void({ saleId: 'sale-1', sellerId: 'seller-1', actorUserId: 'user-1', reason: 'x' }),
|
||||
(error) => error.code === 'SALE_VOID_REASON_REQUIRED' && error.statusCode === 400,
|
||||
);
|
||||
});
|
||||
|
||||
test('creates one sale containing multiple inventory items', async () => {
|
||||
let received;
|
||||
const service = createSaleService({
|
||||
saleRepository: {
|
||||
async createForInventoryItems(input) {
|
||||
received = input;
|
||||
return {
|
||||
status: 'CREATED',
|
||||
sale: { id: 'sale-2', soldPriceMinor: 3000 },
|
||||
saleItems: input.items,
|
||||
saleItem: input.items[0],
|
||||
inventoryItems: input.items.map(({ inventoryItemId }) => ({ id: inventoryItemId, status: 'SOLD' })),
|
||||
inventoryItem: { id: input.items[0].inventoryItemId, status: 'SOLD' },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.create({
|
||||
sellerId: 'seller-1',
|
||||
saleDate: '2026-09-09',
|
||||
items: [
|
||||
{ inventoryItemId: 'inventory-1', soldPriceMinor: 1200, costBasisMinor: 500 },
|
||||
{ inventoryItemId: 'inventory-2', soldPriceMinor: 1800, costBasisMinor: 700 },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(received.sale.soldPriceMinor, 3000);
|
||||
assert.equal(received.items.length, 2);
|
||||
assert.equal(result.profitMinor, 1800);
|
||||
});
|
||||
|
||||
test('maps missing and already-sold inventory to application errors', async () => {
|
||||
for (const result of [
|
||||
{ status: 'NOT_FOUND', code: 'INVENTORY_NOT_FOUND', statusCode: 404 },
|
||||
{ status: 'NOT_ACTIVE', code: 'INVENTORY_NOT_AVAILABLE_FOR_SALE', statusCode: 409 },
|
||||
]) {
|
||||
const service = createSaleService({
|
||||
saleRepository: { async createForInventoryItem() { return result; } },
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.create(validInput()),
|
||||
(error) => error.code === result.code && error.statusCode === result.statusCode,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -54,4 +54,23 @@ test('rejects invalid sales and negative fee values', () => {
|
||||
}),
|
||||
/must be a non-negative integer minor unit/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => createSale({
|
||||
sellerId: 'seller-id',
|
||||
saleDate: '2026-02-30',
|
||||
soldPriceMinor: 2000,
|
||||
}),
|
||||
/must be a valid ISO date/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => createSale({
|
||||
sellerId: 'seller-id',
|
||||
saleDate: '2026-09-08',
|
||||
soldPriceMinor: 2000,
|
||||
currency: 'US dollars',
|
||||
}),
|
||||
/must be a three-letter ISO currency code/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildApp } from '../../src/app/build-app.js';
|
||||
|
||||
const saleResult = {
|
||||
sale: {
|
||||
id: 'sale-1',
|
||||
sellerId: 'seller-1',
|
||||
saleDate: '2026-09-09',
|
||||
soldPriceMinor: 2499,
|
||||
currency: 'USD',
|
||||
platformFeesMinor: 0,
|
||||
paymentProcessingFeesMinor: 0,
|
||||
shippingCostMinor: 0,
|
||||
taxesMinor: 0,
|
||||
otherFeesMinor: 0,
|
||||
},
|
||||
saleItem: {
|
||||
id: 'sale-item-1',
|
||||
inventoryItemId: 'inventory-1',
|
||||
soldPriceMinor: 2499,
|
||||
costBasisMinor: 1200,
|
||||
},
|
||||
inventoryItem: { id: 'inventory-1', status: 'SOLD' },
|
||||
};
|
||||
|
||||
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
||||
return buildApp({
|
||||
authenticateAdmin,
|
||||
authorizeUnsafeRequest,
|
||||
saleService: {
|
||||
async create(input) {
|
||||
assert.equal(input.sellerId, 'seller-1');
|
||||
return saleResult;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('creates a sale for the authenticated seller with CSRF protection', async (testContext) => {
|
||||
let csrfChecks = 0;
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
authorizeUnsafeRequest: async () => { csrfChecks += 1; },
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/sales',
|
||||
headers: { 'x-csrf-token': 'csrf-token' },
|
||||
payload: {
|
||||
inventoryItemId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8',
|
||||
costBasisMinor: 1200,
|
||||
saleDate: '2026-09-09',
|
||||
soldPriceMinor: 2499,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 201);
|
||||
assert.equal(response.json().data.inventoryItem.status, 'SOLD');
|
||||
assert.equal(csrfChecks, 1);
|
||||
});
|
||||
|
||||
test('rejects invalid sale requests and unauthenticated requests', async (testContext) => {
|
||||
const app = createTestApp({ authenticateAdmin: async () => ({ sellerId: 'seller-1' }) });
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const invalidResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/sales',
|
||||
payload: {
|
||||
inventoryItemId: 'not-a-uuid',
|
||||
costBasisMinor: -1,
|
||||
saleDate: 'tomorrow',
|
||||
soldPriceMinor: 0,
|
||||
},
|
||||
});
|
||||
assert.equal(invalidResponse.statusCode, 400);
|
||||
assert.equal(invalidResponse.json().error.code, 'VALIDATION_ERROR');
|
||||
|
||||
const unauthenticatedApp = createTestApp();
|
||||
testContext.after(() => unauthenticatedApp.close());
|
||||
const unauthenticatedResponse = await unauthenticatedApp.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/sales',
|
||||
payload: {},
|
||||
});
|
||||
assert.equal(unauthenticatedResponse.statusCode, 401);
|
||||
assert.equal(unauthenticatedResponse.json().error.code, 'UNAUTHORIZED');
|
||||
});
|
||||
|
||||
test('voids a sale with CSRF protection and actor identity', async (testContext) => {
|
||||
let voidInput;
|
||||
const app = buildApp({
|
||||
authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }),
|
||||
authorizeUnsafeRequest: async () => {},
|
||||
saleService: {
|
||||
async void(input) {
|
||||
voidInput = input;
|
||||
return saleResult;
|
||||
},
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/sales/sale-1/void',
|
||||
headers: { 'x-csrf-token': 'csrf-token' },
|
||||
payload: { reason: 'Duplicate entry' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(voidInput.actorUserId, 'user-1');
|
||||
assert.equal(response.json().data.inventoryItem.status, 'SOLD');
|
||||
});
|
||||
Reference in New Issue
Block a user