Files
pokemon-inventory/apps/api/src/modules/sales/services/sale-service.js
T
fragginwagon 8e5ce80196
Quality / test (push) Canceled after 0s
feat: complete inventory image and sales workflows
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.
2026-09-09 00:35:57 -04:00

76 lines
2.8 KiB
JavaScript

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),
};
}