Skip to content

Commit 312edc1

Browse files
authored
Merge pull request #288 from map-of-pi/dev
Automated PR to merge dev to main
2 parents 2ee20ac + 3a7921c commit 312edc1

File tree

6 files changed

+255
-47
lines changed

6 files changed

+255
-47
lines changed

src/errors/StockValidationError.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export class StockValidationError extends Error {
2+
constructor(message: string, public itemId?: string) {
3+
super(message);
4+
this.name = 'StockValidationError';
5+
}
6+
}

src/helpers/order.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { StockLevelType } from "../models/enums/stockLevelType";
2+
import { StockValidationError } from "../errors/StockValidationError";
3+
4+
export function getUpdatedStockLevel(
5+
currentLevel: StockLevelType,
6+
quantity: number,
7+
itemId: string
8+
): StockLevelType | null {
9+
// Helper to throw error for exceeding max quantity
10+
const throwIfOver = (max: number) => {
11+
if (quantity > max) {
12+
throw new StockValidationError(
13+
`Cannot order more than ${max} item${max > 1 ? 's' : ''} for "${max} available"`,
14+
itemId
15+
);
16+
}
17+
};
18+
19+
switch (currentLevel) {
20+
case StockLevelType.AVAILABLE_1:
21+
throwIfOver(1);
22+
return StockLevelType.SOLD;
23+
24+
case StockLevelType.AVAILABLE_2:
25+
throwIfOver(2);
26+
return quantity === 2 ? StockLevelType.SOLD : StockLevelType.AVAILABLE_1;
27+
28+
case StockLevelType.AVAILABLE_3:
29+
throwIfOver(3);
30+
// Map quantity to resulting stock level explicitly
31+
if (quantity === 3) return StockLevelType.SOLD;
32+
if (quantity === 2) return StockLevelType.AVAILABLE_1;
33+
return StockLevelType.AVAILABLE_2;
34+
35+
case StockLevelType.MANY_AVAILABLE:
36+
case StockLevelType.MADE_TO_ORDER:
37+
case StockLevelType.ONGOING_SERVICE:
38+
return null; // No update needed
39+
40+
default:
41+
throw new StockValidationError(`Unhandled stock level type`, itemId);
42+
}
43+
}
44+
45+
export function getRollbackStockLevel(
46+
currentStock: StockLevelType,
47+
quantity: number
48+
): StockLevelType | null {
49+
// Helper to map quantity to stock level for rollback from SOLD
50+
const soldRollbackMap: Record<number, StockLevelType> = {
51+
1: StockLevelType.AVAILABLE_1,
52+
2: StockLevelType.AVAILABLE_2,
53+
3: StockLevelType.AVAILABLE_3,
54+
};
55+
56+
switch (currentStock) {
57+
case StockLevelType.SOLD:
58+
return soldRollbackMap[quantity] ?? null;
59+
60+
case StockLevelType.AVAILABLE_1:
61+
if (quantity === 1) return StockLevelType.AVAILABLE_2;
62+
if (quantity === 2) return StockLevelType.AVAILABLE_3;
63+
return null;
64+
65+
case StockLevelType.AVAILABLE_2:
66+
if (quantity === 1) return StockLevelType.AVAILABLE_3;
67+
return null;
68+
69+
// For non-limited stock types, we assume no rollback is needed
70+
case StockLevelType.MANY_AVAILABLE:
71+
case StockLevelType.MADE_TO_ORDER:
72+
case StockLevelType.ONGOING_SERVICE:
73+
return null;
74+
75+
default:
76+
return null;
77+
}
78+
}

src/services/order.service.ts

Lines changed: 99 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
import mongoose from "mongoose";
2+
import {
3+
getRollbackStockLevel,
4+
getUpdatedStockLevel
5+
} from "../helpers/order";
6+
import { StockValidationError } from "../errors/StockValidationError";
27
import Order from "../models/Order";
38
import OrderItem from "../models/OrderItem";
49
import Seller from "../models/Seller";
@@ -29,57 +34,69 @@ export const createOrder = async (
2934
is_fulfilled: false,
3035
fulfillment_method: orderData.fulfillmentMethod,
3136
seller_fulfillment_description: orderData.sellerFulfillmentDescription,
32-
buyer_fulfillment_description: orderData.buyerFulfillmentDescription,
37+
buyer_fulfillment_description: orderData.buyerFulfillmentDescription,
3338
});
3439
const newOrder = await order.save({ session });
35-
36-
if (!newOrder) {
37-
logger.error('Failed to create order; save returned null');
38-
throw new Error('Failed to create order');
39-
}
40-
logger.debug('Order created successfully', { orderId: newOrder._id });
40+
if (!newOrder) throw new Error('Failed to create order');
4141

4242
/* Step 2: Fetch all SellerItem documents associated with the order */
4343
const sellerItemIds = orderItems.map((item) => item.itemId);
44-
const sellerItems = await SellerItem.find({ _id: { $in: sellerItemIds } }).lean();
44+
const sellerItems = await SellerItem.find({ _id: { $in: sellerItemIds } }).session(session);
45+
const sellerItemMap = new Map(sellerItems.map((doc) => [doc._id.toString(), doc]));
4546

46-
// Build a lookup map for seller items
47-
const sellerItemLookup = sellerItems.reduce((acc, sellerItem) => {
48-
acc[sellerItem._id.toString()] = sellerItem;
49-
return acc;
50-
}, {} as Record<string, any>);
47+
const bulkOrderItems = [];
48+
const bulkSellerItemUpdates = [];
5149

5250
/* Step 3: Build OrderItem documents for bulk insertion */
53-
const bulkOrderItems = orderItems.map((item) => {
54-
const sellerItem = sellerItemLookup[item.itemId];
51+
for (const item of orderItems) {
52+
const sellerItem = sellerItemMap.get(item.itemId);
5553
if (!sellerItem) {
56-
logger.error(`Failed to find seller item for ID: ${ item.itemId }`);
54+
logger.error(`Seller item not found for ID: ${item.itemId}`);
5755
throw new Error('Failed to find associated seller item');
5856
}
5957

60-
return {
58+
// Validate and get new stock level
59+
const newStockLevel = getUpdatedStockLevel(sellerItem.stock_level, item.quantity, item.itemId);
60+
if (newStockLevel !== null) {
61+
bulkSellerItemUpdates.push({
62+
updateOne: {
63+
filter: { _id: sellerItem._id },
64+
update: { $set: { stock_level: newStockLevel } },
65+
},
66+
});
67+
}
68+
69+
const subtotal = item.quantity * parseFloat(sellerItem.price.toString());
70+
bulkOrderItems.push({
6171
order_id: newOrder._id,
62-
seller_item_id: sellerItem._id, // Store only the ObjectId
72+
seller_item_id: sellerItem._id,
6373
quantity: item.quantity,
64-
subtotal: item.quantity * parseFloat(sellerItem.price.toString()),
74+
subtotal,
6575
status: OrderItemStatusType.Pending,
66-
};
67-
});
76+
});
77+
}
6878

6979
/* Step 4: Insert order items in bulk */
7080
await OrderItem.insertMany(bulkOrderItems, { session });
71-
logger.debug('Order items inserted successfully', { count: bulkOrderItems.length });
81+
82+
if (bulkSellerItemUpdates.length > 0) {
83+
await SellerItem.bulkWrite(bulkSellerItemUpdates, { session });
84+
}
7285

7386
/* Step 5: Commit the transaction */
7487
await session.commitTransaction();
88+
logger.info('Order and stock levels created/updated successfully', { orderId: newOrder._id });
7589

76-
logger.info('Order and associated items created successfully', { orderId: newOrder._id });
7790
return newOrder;
7891
} catch (error: any) {
79-
/* Step 6: Roll back transaction on failure */
8092
await session.abortTransaction();
8193

82-
logger.error(`Failed to create order: ${ error }`);
94+
if (error instanceof StockValidationError) {
95+
logger.warn(`Stock validation failed: ${error.message}`, { itemId: error.itemId });
96+
} else {
97+
logger.error(`Failed to create order and update stock: ${error}`);
98+
}
99+
83100
throw error;
84101
} finally {
85102
session.endSession();
@@ -266,28 +283,73 @@ export const updateOrderItemStatus = async (
266283
logger.error(`Failed to update order item status for orderItemID ${ itemId }: ${ error }`);
267284
throw error;
268285
}
269-
};
286+
}
270287

271288
export const cancelOrder = async (paymentId: string) => {
289+
const session = await mongoose.startSession();
290+
272291
try {
292+
session.startTransaction();
293+
294+
/* Step 1: Cancel the order */
273295
const cancelledOrder = await Order.findOneAndUpdate(
274-
{ payment_id: paymentId },
275-
{
296+
{ payment_id: paymentId },
297+
{
276298
is_paid: false,
277-
status: OrderStatusType.Cancelled
299+
status: OrderStatusType.Cancelled,
278300
},
279-
{ new: true }
280-
).exec();
301+
{ new: true, session }
302+
);
281303

282304
if (!cancelledOrder) {
283-
logger.error(`Failed to cancel order for paymentID ${ paymentId }`);
284-
throw new Error("Failed to cancel order");
285-
}
305+
logger.error(`Failed to cancel order for paymentID ${paymentId}`);
306+
throw new Error('Failed to cancel order');
307+
}
308+
309+
/* Step 2: Get order items */
310+
const orderItems = await OrderItem.find({
311+
order_id: cancelledOrder._id,
312+
status: OrderItemStatusType.Pending,
313+
}).session(session);
314+
315+
if (orderItems.length === 0) {
316+
logger.warn(`No pending order items found for order ${cancelledOrder._id}`);
317+
}
286318

287-
logger.info(`Order with paymentID ${ paymentId } successfully cancelled.`);
319+
/* Step 3: Get related seller items */
320+
const sellerItemIds = orderItems.map((item) => item.seller_item_id);
321+
const sellerItems = await SellerItem.find({ _id: { $in: sellerItemIds } }).session(session);
322+
const sellerItemMap = new Map(sellerItems.map((item) => [item._id.toString(), item]));
323+
324+
const bulkSellerItemUpdates = [];
325+
326+
for (const item of orderItems) {
327+
const sellerItem = sellerItemMap.get(item.seller_item_id.toString());
328+
if (!sellerItem) continue;
329+
330+
const restoredLevel = getRollbackStockLevel(sellerItem.stock_level, item.quantity);
331+
if (restoredLevel !== null) {
332+
bulkSellerItemUpdates.push({
333+
updateOne: {
334+
filter: { _id: sellerItem._id },
335+
update: { $set: { stock_level: restoredLevel } },
336+
},
337+
});
338+
}
339+
}
340+
341+
if (bulkSellerItemUpdates.length > 0) {
342+
await SellerItem.bulkWrite(bulkSellerItemUpdates, { session });
343+
}
344+
345+
await session.commitTransaction();
346+
logger.info(`Order with paymentID ${paymentId} successfully cancelled and stock levels restored.`);
288347
return cancelledOrder;
289-
} catch (error:any) {
290-
logger.error(`Failed to cancel order for paymentID ${ paymentId }: ${ error }`);
348+
} catch (error: any) {
349+
await session.abortTransaction();
350+
logger.error(`Failed to cancel order for paymentID ${paymentId}: ${error.message}`);
291351
throw error;
352+
} finally {
353+
session.endSession();
292354
}
293355
};

test/helpers/order.spec.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { getUpdatedStockLevel } from "../../src/helpers/order";
2+
import { StockLevelType } from "../../src/models/enums/stockLevelType";
3+
import { StockValidationError } from "../../src/errors/StockValidationError";
4+
5+
describe('getUpdatedStockLevel function', () => {
6+
const itemId = 'itemId_TEST'
7+
8+
it('should handle AVAILABLE_1: quantity 1 returns SOLD', () => {
9+
expect(getUpdatedStockLevel(StockLevelType.AVAILABLE_1, 1, itemId)).toBe(StockLevelType.SOLD);
10+
});
11+
12+
it('should handle AVAILABLE_1: quantity > 1 throws error', () => {
13+
expect(() => getUpdatedStockLevel(StockLevelType.AVAILABLE_1, 2, itemId)).toThrow(StockValidationError);
14+
});
15+
16+
it('should handle AVAILABLE_2: quantity 1 returns AVAILABLE_1', () => {
17+
expect(getUpdatedStockLevel(StockLevelType.AVAILABLE_2, 1, itemId)).toBe(StockLevelType.AVAILABLE_1);
18+
});
19+
20+
it('should handle AVAILABLE_2: quantity 2 returns SOLD', () => {
21+
expect(getUpdatedStockLevel(StockLevelType.AVAILABLE_2, 2, itemId)).toBe(StockLevelType.SOLD);
22+
});
23+
24+
it('should handle AVAILABLE_2: quantity > 2 throws error', () => {
25+
expect(() => getUpdatedStockLevel(StockLevelType.AVAILABLE_2, 3, itemId)).toThrow(StockValidationError);
26+
});
27+
28+
it('should handle AVAILABLE_3: quantity 1 returns AVAILABLE_2', () => {
29+
expect(getUpdatedStockLevel(StockLevelType.AVAILABLE_3, 1, itemId)).toBe(StockLevelType.AVAILABLE_2);
30+
});
31+
32+
it('should handle AVAILABLE_3: quantity 2 returns AVAILABLE_1', () => {
33+
expect(getUpdatedStockLevel(StockLevelType.AVAILABLE_3, 2, itemId)).toBe(StockLevelType.AVAILABLE_1);
34+
});
35+
36+
it('should handle AVAILABLE_3: quantity 3 returns SOLD', () => {
37+
expect(getUpdatedStockLevel(StockLevelType.AVAILABLE_3, 3, itemId)).toBe(StockLevelType.SOLD);
38+
});
39+
40+
it('should handle AVAILABLE_3: quantity > 3 throws error', () => {
41+
expect(() => getUpdatedStockLevel(StockLevelType.AVAILABLE_3, 4, itemId)).toThrow(StockValidationError);
42+
});
43+
44+
it('should handle MANY_AVAILABLE: regardless of quantity returns null', () => {
45+
expect(getUpdatedStockLevel(StockLevelType.MANY_AVAILABLE, 10, itemId)).toBeNull();
46+
});
47+
48+
it('should handle MADE_TO_ORDER: regardless of quantity returns null', () => {
49+
expect(getUpdatedStockLevel(StockLevelType.MADE_TO_ORDER, 5, itemId)).toBeNull();
50+
});
51+
52+
it('should handle ONGOING_SERVICE: regardless of quantity returns null', () => {
53+
expect(getUpdatedStockLevel(StockLevelType.ONGOING_SERVICE, 1, itemId)).toBeNull();
54+
});
55+
56+
it('should throw StockValidationError if stock level is unhandled', () => {
57+
expect(() => getUpdatedStockLevel('UNKNOWN_LEVEL' as StockLevelType, 1, itemId)).toThrow(StockValidationError);
58+
});
59+
});

test/jest.setup.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ jest.mock('../src/config/loggingConfig', () => ({
1919
}));
2020

2121
// allow ample time to start running tests
22-
jest.setTimeout(300000);
22+
// TODO - replace mockData.json file w/ mocks to lessen timeout value.
23+
jest.setTimeout(500000);
2324

2425
// MongoDB memory server setup
2526
let mongoServer: MongoMemoryServer;

0 commit comments

Comments
 (0)