-
Notifications
You must be signed in to change notification settings - Fork 2
Description
Problem
The inventory deduction logic is intentionally duplicated between checkout.service.ts and inventory.service.ts (as noted in comments at line 456). While the duplication was done for a valid reason (ensuring atomicity within a Prisma transaction), this pattern creates a maintenance burden:
- Synchronization risk: Changes to inventory logic must be manually kept in sync across both implementations
- Bug multiplication: A bug in the logic requires fixes in multiple places
- Testing overhead: The same logic must be tested in multiple contexts
- Code comprehension: Future developers must understand why the duplication exists
The comment explicitly states: "Both implementations must be kept in sync when making changes" - this is a code smell indicating the need for better abstraction.
Current Code Location
- Primary implementation:
src/lib/services/inventory.service.ts(deductStockForOrder method) - Duplicated implementation:
src/lib/services/checkout.service.ts(lines 456-520, inside createOrder transaction) - Duplication reason: Transaction atomicity - order creation and inventory deduction must occur in the same Prisma transaction
- Complexity: Medium (65+ lines of duplicated logic with variant and product-level handling)
Proposed Refactoring
Extract the shared inventory deduction logic into a transaction-safe helper function that can be called from within any Prisma transaction. This eliminates duplication while maintaining the required atomicity guarantee.
Benefits
- Single source of truth: Inventory logic exists in one place
- Easier maintenance: Changes to inventory rules happen once
- Reduced bug risk: Fixes apply everywhere automatically
- Better testability: Core logic can be tested independently of transaction context
- Clear ownership: InventoryService owns all inventory operations
- Documentation: One place to document inventory business rules
Suggested Approach
-
Create transaction-safe inventory helper (
src/lib/services/inventory.service.ts):import { Prisma } from '`@prisma/client`'; export class InventoryService { // ... existing methods ... /** * Deduct inventory for order items within a Prisma transaction * This method is transaction-safe and can be called from any tx context * * `@param` tx - Prisma transaction client * `@param` storeId - Store ID for multi-tenant validation * `@param` items - Order items with quantity to deduct * `@throws` Error if insufficient stock or item not found * * `@example` * await prisma.$transaction(async (tx) => { * await inventoryService.deductStockInTransaction(tx, storeId, orderItems); * // ... other transaction operations * }); */ async deductStockInTransaction( tx: Prisma.TransactionClient, storeId: string, items: Array<{ productId: string; variantId?: string; quantity: number; }> ): Promise(void) { for (const item of items) { if (item.variantId) { // Variant-level stock deduction const variant = await tx.productVariant.findUnique({ where: { id: item.variantId }, select: { inventoryQty: true, lowStockThreshold: true, name: true, product: { select: { name: true, storeId: true, trackInventory: true } }, }, }); if (!variant || variant.product.storeId !== storeId) { throw new Error(`Variant \$\{item.variantId} not found in store \$\{storeId}`); } // Skip inventory check if product doesn't track inventory if (!variant.product.trackInventory) { continue; } const newQty = variant.inventoryQty - item.quantity; if (newQty < 0) { throw new Error( `Insufficient stock for "\$\{variant.product.name} - \$\{variant.name}". ` + `Available: \$\{variant.inventoryQty}, Requested: \$\{item.quantity}` ); } // Update variant inventory await tx.productVariant.update({ where: { id: item.variantId }, data: { inventoryQty: newQty, updatedAt: new Date() }, }); // Update product-level inventory status if all variants are low/out of stock await this.updateProductInventoryStatus(tx, variant.product.name, storeId); } else { // Product-level stock deduction (no variants) const product = await tx.product.findUnique({ where: { id: item.productId }, select: { inventoryQty: true, lowStockThreshold: true, name: true, storeId: true, trackInventory: true, }, }); if (!product || product.storeId !== storeId) { throw new Error(`Product \$\{item.productId} not found in store \$\{storeId}`); } if (!product.trackInventory) { continue; } const newQty = product.inventoryQty - item.quantity; if (newQty < 0) { throw new Error( `Insufficient stock for "\$\{product.name}". ` + `Available: \$\{product.inventoryQty}, Requested: \$\{item.quantity}` ); } // Calculate new inventory status const inventoryStatus = newQty === 0 ? 'OUT_OF_STOCK' : newQty <= product.lowStockThreshold ? 'LOW_STOCK' : 'IN_STOCK'; // Update product inventory await tx.product.update({ where: { id: item.productId }, data: { inventoryQty: newQty, inventoryStatus, updatedAt: new Date() }, }); } } } /** * Update product-level inventory status based on all variants * Called internally after variant stock changes */ private async updateProductInventoryStatus( tx: Prisma.TransactionClient, productId: string, storeId: string ): Promise(void) { // Aggregate variant stock to determine product status const variants = await tx.productVariant.findMany({ where: { productId }, select: { inventoryQty: true, lowStockThreshold: true }, }); const totalQty = variants.reduce((sum, v) => sum + v.inventoryQty, 0); const allOutOfStock = variants.every(v => v.inventoryQty === 0); const anyLowStock = variants.some(v => v.inventoryQty > 0 && v.inventoryQty <= v.lowStockThreshold ); const inventoryStatus = allOutOfStock ? 'OUT_OF_STOCK' : anyLowStock ? 'LOW_STOCK' : 'IN_STOCK'; await tx.product.update({ where: { id: productId }, data: { inventoryQty: totalQty, inventoryStatus, updatedAt: new Date() }, }); } }
-
Update checkout.service.ts to use the helper:
import { inventoryService } from '@/lib/services/inventory.service'; async createOrder(input: CreateOrderInput): Promise(Order) { // ... validation ... return await prisma.$transaction(async (tx) => { // Create order const order = await tx.order.create({ data: orderData, }); // Deduct inventory using shared helper (no longer duplicated!) await inventoryService.deductStockInTransaction( tx, input.storeId, validated.items.map(item => ({ productId: item.productId, variantId: item.variantId, quantity: item.quantity, })) ); // ... create order items, payment record, etc. ... return order; }); }
-
Update existing deductStockForOrder to use the same helper:
// src/lib/services/inventory.service.ts async deductStockForOrder( storeId: string, items: Array<{ productId: string; variantId?: string; quantity: number }> ): Promise(void) { // Wrap in transaction and delegate to shared helper return await prisma.$transaction(async (tx) => { await this.deductStockInTransaction(tx, storeId, items); }); }
-
Add comprehensive tests for the extracted logic:
// src/lib/services/__tests__/inventory.service.test.ts describe('InventoryService.deductStockInTransaction', () => { it('deducts variant stock correctly', async () => { // Test variant stock deduction }); it('deducts product stock when no variants', async () => { // Test product-level stock deduction }); it('throws error on insufficient stock', async () => { // Test error handling }); it('updates product inventory status after variant changes', async () => { // Test status calculation }); it('respects trackInventory flag', async () => { // Test that products not tracking inventory are skipped }); it('validates store ownership', async () => { // Test multi-tenant isolation }); });
Code Example
Before (duplicated logic):
// src/lib/services/checkout.service.ts (lines 456-520)
// NOTE: This logic is intentionally duplicated here (also in InventoryService.deductStockForOrder)
// because order creation + inventory deduction MUST happen in the SAME Prisma transaction...
for (const item of validated.items) {
if (item.variantId) {
const variant = await tx.productVariant.findUnique({
where: { id: item.variantId },
// ... 40+ lines of duplicated logic ...
});
// ... deduction logic ...
} else {
const product = await tx.product.findUnique({
// ... 25+ lines of duplicated logic ...
});
// ... deduction logic ...
}
}
// src/lib/services/inventory.service.ts
async deductStockForOrder(storeId: string, items: OrderItem[]): Promise(void) {
return await prisma.$transaction(async (tx) => {
for (const item of items) {
// ... 65+ lines of IDENTICAL logic (must be kept in sync!) ...
}
});
}After (single source of truth):
// src/lib/services/inventory.service.ts
async deductStockInTransaction(
tx: Prisma.TransactionClient,
storeId: string,
items: Array<{...}>
): Promise(void) {
// 65+ lines of inventory logic - EXISTS ONCE
for (const item of items) {
// ... comprehensive inventory deduction logic ...
}
}
// src/lib/services/checkout.service.ts
await prisma.$transaction(async (tx) => {
const order = await tx.order.create({ data: orderData });
// No duplication - just call the shared helper!
await inventoryService.deductStockInTransaction(tx, storeId, validated.items);
// ... rest of transaction ...
});
// src/lib/services/inventory.service.ts (public API)
async deductStockForOrder(storeId: string, items: OrderItem[]): Promise(void) {
return await prisma.$transaction(async (tx) => {
// Also uses the same helper - no duplication!
await this.deductStockInTransaction(tx, storeId, items);
});
}Impact Assessment
-
Effort: Medium - Estimated 4-6 hours
- 2-3 hours: Extract and refactor the transaction-safe helper
- 1-2 hours: Update both service methods to use the helper
- 1-2 hours: Add comprehensive tests for extracted logic
-
Risk: Medium
- Critical business logic (inventory management)
- Must maintain transaction atomicity
- Requires careful testing with various scenarios (variants, no variants, insufficient stock)
- Rollback possible if issues arise (duplication still exists in git history)
-
Benefit: High
- Eliminates 65+ lines of duplicated code
- Prevents synchronization bugs between implementations
- Provides single place for inventory logic changes
- Improves testability of core inventory logic
- Documents transaction safety requirements clearly
-
Priority: High - Reduces technical debt and prevents future synchronization bugs
Related Files
Files to modify:
src/lib/services/inventory.service.ts(adddeductStockInTransactionmethod, ~80 new lines)src/lib/services/checkout.service.ts(remove duplication, call helper instead, ~60 lines removed)
Files to create:
src/lib/services/__tests__/inventory.service.test.ts(new test file for extracted logic)
Testing Strategy
-
Unit tests for the extracted helper:
describe('deductStockInTransaction', () => { it('handles variant stock deduction correctly', async () => { // Mock transaction client const mockTx = createMockTransactionClient(); await inventoryService.deductStockInTransaction( mockTx, 'store-123', [{ productId: 'prod-1', variantId: 'var-1', quantity: 2 }] ); expect(mockTx.productVariant.update).toHaveBeenCalledWith({ where: { id: 'var-1' }, data: { inventoryQty: expect.any(Number) } }); }); it('throws on insufficient stock', async () => { // Test error handling }); it('respects trackInventory flag', async () => { // Test that inventory is not deducted when trackInventory is false }); });
-
Integration tests for checkout flow:
describe('CheckoutService.createOrder with inventory', () => { it('deducts inventory atomically during order creation', async () => { const order = await checkoutService.createOrder({ storeId: 'store-123', items: [{ productId: 'prod-1', quantity: 2 }], // ... }); // Verify order created AND inventory deducted expect(order).toBeDefined(); const product = await prisma.product.findUnique({ where: { id: 'prod-1' } }); expect(product.inventoryQty).toBe(8); // Was 10, now 8 }); it('rolls back order if inventory deduction fails', async () => { // Test atomicity - order should not exist if inventory fails }); });
-
Manual testing checklist:
- Create order with product (no variants) - inventory deducted correctly
- Create order with variants - variant inventory deducted correctly
- Try to create order with insufficient stock - error thrown, transaction rolled back
- Create order with product that doesn't track inventory - order succeeds, no inventory change
- Verify inventory status updates correctly (IN_STOCK → LOW_STOCK → OUT_OF_STOCK)
- Test with multiple items in same order
- Test concurrent order creation (race conditions)
Success Criteria
-
deductStockInTransactionmethod added to InventoryService - Duplication removed from checkout.service.ts (65+ lines deleted)
- Both services now call the shared helper
- All existing integration tests pass
- New unit tests added for extracted logic (80%+ coverage)
- Manual testing checklist completed
- Transaction atomicity verified (order + inventory in same transaction)
- Performance benchmarks show no regression
- Code review approved with focus on transaction safety
AI generated by Daily Codebase Analyzer - Semantic Function Extraction & Refactoring
- expires on Mar 8, 2026, 1:41 PM UTC
Metadata
Metadata
Assignees
Type
Projects
Status