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.
76 lines
2.8 KiB
JavaScript
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),
|
|
};
|
|
} |