Skip to content

[Refactoring] Services: Extract shared inventory deduction logic to eliminate intentional duplication #282

@syed-reza98

Description

@syed-reza98

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:

  1. Synchronization risk: Changes to inventory logic must be manually kept in sync across both implementations
  2. Bug multiplication: A bug in the logic requires fixes in multiple places
  3. Testing overhead: The same logic must be tested in multiple contexts
  4. 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

  1. 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()
          },
        });
      }
    }
  2. 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;
      });
    }
  3. 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);
      });
    }
  4. 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 (add deductStockInTransaction method, ~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

  1. 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
      });
    });
  2. 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
      });
    });
  3. 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

  • deductStockInTransaction method 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

No one assigned

    Type

    No type

    Projects

    Status

    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions