From 36d9e0a6d786eeffd4d00d7a2e5871ae9e923b35 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 03:01:50 +0000 Subject: [PATCH 1/4] Initial plan From de9b2646e9f948e609a9d54a78c64e98a975af22 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 03:06:27 +0000 Subject: [PATCH 2/4] Initial analysis - preparing to implement Inventory Reservation System Co-authored-by: AshrafAbir <88766326+AshrafAbir@users.noreply.github.com> --- .../migrations/20251129030447/migration.sql | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 prisma/migrations/20251129030447/migration.sql diff --git a/prisma/migrations/20251129030447/migration.sql b/prisma/migrations/20251129030447/migration.sql new file mode 100644 index 00000000..0b4a70a4 --- /dev/null +++ b/prisma/migrations/20251129030447/migration.sql @@ -0,0 +1,59 @@ +-- DropIndex +DROP INDEX "Category_storeId_slug_idx"; + +-- DropIndex +DROP INDEX "Category_slug_idx"; + +-- DropIndex +DROP INDEX "Customer_email_storeId_idx"; + +-- DropIndex +DROP INDEX "Customer_storeId_email_idx"; + +-- DropIndex +DROP INDEX "Order_customerId_createdAt_idx"; + +-- DropIndex +DROP INDEX "Order_paymentStatus_idx"; + +-- DropIndex +DROP INDEX "Order_orderNumber_idx"; + +-- DropIndex +DROP INDEX "ProductAttribute_name_idx"; + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_InventoryLog" ( + "id" TEXT NOT NULL PRIMARY KEY, + "storeId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "variantId" TEXT, + "orderId" TEXT, + "previousQty" INTEGER NOT NULL, + "newQty" INTEGER NOT NULL, + "changeQty" INTEGER NOT NULL, + "reason" TEXT NOT NULL, + "note" TEXT, + "userId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "InventoryLog_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "InventoryLog_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "InventoryLog_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "ProductVariant" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "InventoryLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_InventoryLog" ("changeQty", "createdAt", "id", "newQty", "note", "previousQty", "productId", "reason", "storeId", "userId") SELECT "changeQty", "createdAt", "id", "newQty", "note", "previousQty", "productId", "reason", "storeId", "userId" FROM "InventoryLog"; +DROP TABLE "InventoryLog"; +ALTER TABLE "new_InventoryLog" RENAME TO "InventoryLog"; +CREATE INDEX "InventoryLog_storeId_productId_createdAt_idx" ON "InventoryLog"("storeId", "productId", "createdAt"); +CREATE INDEX "InventoryLog_productId_createdAt_idx" ON "InventoryLog"("productId", "createdAt"); +CREATE INDEX "InventoryLog_variantId_createdAt_idx" ON "InventoryLog"("variantId", "createdAt"); +CREATE INDEX "InventoryLog_orderId_idx" ON "InventoryLog"("orderId"); +CREATE INDEX "InventoryLog_userId_createdAt_idx" ON "InventoryLog"("userId", "createdAt"); +CREATE INDEX "InventoryLog_reason_idx" ON "InventoryLog"("reason"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE INDEX "Order_storeId_customerId_createdAt_idx" ON "Order"("storeId", "customerId", "createdAt"); From e20443d3428bb860d4db631bd5c733efa0f2550d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 03:12:57 +0000 Subject: [PATCH 3/4] Add Inventory Reservation System with model, service, and API routes Co-authored-by: AshrafAbir <88766326+AshrafAbir@users.noreply.github.com> --- .../migration.sql | 36 ++ prisma/schema.prisma | 38 ++ src/app/api/inventory/extend/route.ts | 109 ++++ src/app/api/inventory/release/route.ts | 116 ++++ src/app/api/inventory/reserve/route.ts | 110 ++++ src/lib/services/checkout.service.ts | 15 + .../services/inventory-reservation.service.ts | 590 ++++++++++++++++++ src/lib/services/inventory.service.ts | 60 +- 8 files changed, 1060 insertions(+), 14 deletions(-) create mode 100644 prisma/migrations/20251129030749_add_inventory_reservation/migration.sql create mode 100644 src/app/api/inventory/extend/route.ts create mode 100644 src/app/api/inventory/release/route.ts create mode 100644 src/app/api/inventory/reserve/route.ts create mode 100644 src/lib/services/inventory-reservation.service.ts diff --git a/prisma/migrations/20251129030749_add_inventory_reservation/migration.sql b/prisma/migrations/20251129030749_add_inventory_reservation/migration.sql new file mode 100644 index 00000000..1ececd09 --- /dev/null +++ b/prisma/migrations/20251129030749_add_inventory_reservation/migration.sql @@ -0,0 +1,36 @@ +-- CreateTable +CREATE TABLE "inventory_reservations" ( + "id" TEXT NOT NULL PRIMARY KEY, + "storeId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "variantId" TEXT, + "quantity" INTEGER NOT NULL, + "status" TEXT NOT NULL DEFAULT 'ACTIVE', + "cartId" TEXT, + "orderId" TEXT, + "expiresAt" DATETIME NOT NULL, + "extendedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "inventory_reservations_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "inventory_reservations_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "inventory_reservations_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "ProductVariant" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "inventory_reservations_storeId_productId_idx" ON "inventory_reservations"("storeId", "productId"); + +-- CreateIndex +CREATE INDEX "inventory_reservations_storeId_variantId_idx" ON "inventory_reservations"("storeId", "variantId"); + +-- CreateIndex +CREATE INDEX "inventory_reservations_expiresAt_idx" ON "inventory_reservations"("expiresAt"); + +-- CreateIndex +CREATE INDEX "inventory_reservations_status_idx" ON "inventory_reservations"("status"); + +-- CreateIndex +CREATE INDEX "inventory_reservations_cartId_idx" ON "inventory_reservations"("cartId"); + +-- CreateIndex +CREATE INDEX "inventory_reservations_orderId_idx" ON "inventory_reservations"("orderId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 068f4a52..8afabe6b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -228,6 +228,13 @@ enum SubscriptionStatus { PAUSED } +enum ReservationStatus { + ACTIVE + CONSUMED + EXPIRED + RELEASED +} + // Store model (E-commerce tenant - extends Organization) model Store { id String @id @default(cuid()) @@ -272,6 +279,7 @@ model Store { attributes ProductAttribute[] auditLogs AuditLog[] inventoryLogs InventoryLog[] @relation("StoreInventoryLogs") + inventoryReservations InventoryReservation[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -333,6 +341,7 @@ model Product { attributes ProductAttributeValue[] reviews Review[] inventoryLogs InventoryLog[] @relation("InventoryLogs") + inventoryReservations InventoryReservation[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -372,6 +381,7 @@ model ProductVariant { orderItems OrderItem[] inventoryLogs InventoryLog[] @relation("VariantInventoryLogs") + inventoryReservations InventoryReservation[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -675,4 +685,32 @@ model AuditLog { @@index([userId, createdAt]) @@index([entityType, entityId, createdAt]) @@map("audit_logs") +} + +// Inventory Reservation model (for cart hold / soft allocation) +model InventoryReservation { + id String @id @default(cuid()) + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + productId String + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + variantId String? + variant ProductVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade) + quantity Int + status ReservationStatus @default(ACTIVE) + cartId String? // Cart session reference + orderId String? // Set when consumed by order + expiresAt DateTime // When reservation expires (default 15 min TTL) + extendedAt DateTime? // Set when reservation is extended (only once allowed) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([storeId, productId]) + @@index([storeId, variantId]) + @@index([expiresAt]) + @@index([status]) + @@index([cartId]) + @@index([orderId]) + @@map("inventory_reservations") } \ No newline at end of file diff --git a/src/app/api/inventory/extend/route.ts b/src/app/api/inventory/extend/route.ts new file mode 100644 index 00000000..925fedce --- /dev/null +++ b/src/app/api/inventory/extend/route.ts @@ -0,0 +1,109 @@ +// src/app/api/inventory/extend/route.ts +// Inventory Reservation Extension API Endpoint +// POST /api/inventory/extend - Extend reservation (once per reservation) + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth'; +import { InventoryReservationService, MAX_EXTENSION_MINUTES } from '@/lib/services/inventory-reservation.service'; +import { z } from 'zod'; + +const extendSchema = z.object({ + storeId: z.string().cuid(), + reservationId: z.string().cuid(), + extensionMinutes: z.number().int().positive().max(MAX_EXTENSION_MINUTES).default(MAX_EXTENSION_MINUTES), +}); + +// POST /api/inventory/extend - Extend a reservation +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const body = await request.json(); + const validatedData = extendSchema.parse(body); + + const { storeId, reservationId, extensionMinutes } = validatedData; + + // Verify store membership to prevent cross-tenant access + const { prisma } = await import('@/lib/prisma'); + const membership = await prisma.membership.findFirst({ + where: { + userId: session.user.id, + organization: { + store: { + id: storeId + } + } + } + }); + + if (!membership) { + return NextResponse.json( + { error: 'Forbidden: You do not have access to this store' }, + { status: 403 } + ); + } + + const reservationService = InventoryReservationService.getInstance(); + + try { + const result = await reservationService.extendReservation( + reservationId, + storeId, + extensionMinutes + ); + + if (!result) { + return NextResponse.json( + { error: 'Reservation not found or already expired' }, + { status: 404 } + ); + } + + return NextResponse.json({ + success: true, + message: 'Reservation extended successfully', + reservation: result, + }); + } catch (extendError) { + // Handle "already extended" error + if (extendError instanceof Error && extendError.message.includes('only be extended once')) { + return NextResponse.json( + { + error: 'Extension failed', + details: extendError.message, + }, + { status: 409 } // Conflict + ); + } + throw extendError; + } + } catch (error) { + console.error('POST /api/inventory/extend error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { + error: 'Validation error', + details: error.issues, + }, + { status: 400 } + ); + } + + return NextResponse.json( + { + error: 'Failed to extend reservation', + details: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/inventory/release/route.ts b/src/app/api/inventory/release/route.ts new file mode 100644 index 00000000..ca3f02ae --- /dev/null +++ b/src/app/api/inventory/release/route.ts @@ -0,0 +1,116 @@ +// src/app/api/inventory/release/route.ts +// Inventory Release API Endpoint +// POST /api/inventory/release - Release reservations manually + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth'; +import { InventoryReservationService } from '@/lib/services/inventory-reservation.service'; +import { z } from 'zod'; + +const releaseSchema = z.object({ + storeId: z.string().cuid(), + // Either reservationId or cartId must be provided + reservationId: z.string().cuid().optional(), + cartId: z.string().optional(), +}).refine( + (data) => data.reservationId || data.cartId, + { message: 'Either reservationId or cartId must be provided' } +); + +// POST /api/inventory/release - Release reservations +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const body = await request.json(); + const validatedData = releaseSchema.parse(body); + + const { storeId, reservationId, cartId } = validatedData; + + // Verify store membership to prevent cross-tenant access + const { prisma } = await import('@/lib/prisma'); + const membership = await prisma.membership.findFirst({ + where: { + userId: session.user.id, + organization: { + store: { + id: storeId + } + } + } + }); + + if (!membership) { + return NextResponse.json( + { error: 'Forbidden: You do not have access to this store' }, + { status: 403 } + ); + } + + const reservationService = InventoryReservationService.getInstance(); + + if (reservationId) { + // Release single reservation + const released = await reservationService.releaseReservation(reservationId, storeId); + + if (!released) { + return NextResponse.json( + { error: 'Reservation not found or already released' }, + { status: 404 } + ); + } + + return NextResponse.json({ + success: true, + message: 'Reservation released successfully', + reservationId, + }); + } else if (cartId) { + // Release all reservations for cart + const count = await reservationService.releaseCartReservations(cartId, storeId); + + return NextResponse.json({ + success: true, + message: count > 0 + ? `${count} reservation(s) released successfully` + : 'No active reservations found for this cart', + releasedCount: count, + cartId, + }); + } + + // This should never happen due to zod validation + return NextResponse.json( + { error: 'Invalid request' }, + { status: 400 } + ); + } catch (error) { + console.error('POST /api/inventory/release error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { + error: 'Validation error', + details: error.issues, + }, + { status: 400 } + ); + } + + return NextResponse.json( + { + error: 'Failed to release reservations', + details: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/inventory/reserve/route.ts b/src/app/api/inventory/reserve/route.ts new file mode 100644 index 00000000..07b76144 --- /dev/null +++ b/src/app/api/inventory/reserve/route.ts @@ -0,0 +1,110 @@ +// src/app/api/inventory/reserve/route.ts +// Inventory Reservation API Endpoint +// POST /api/inventory/reserve - Create batch reservations + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth'; +import { InventoryReservationService, DEFAULT_RESERVATION_TTL_MINUTES } from '@/lib/services/inventory-reservation.service'; +import { z } from 'zod'; + +const reserveSchema = z.object({ + storeId: z.string().cuid(), + items: z.array( + z.object({ + productId: z.string().cuid(), + variantId: z.string().cuid().optional(), + quantity: z.number().int().positive(), + }) + ).min(1).max(50), // Max 50 items per batch + cartId: z.string().optional(), + ttlMinutes: z.number().int().positive().max(60).default(DEFAULT_RESERVATION_TTL_MINUTES), +}); + +// POST /api/inventory/reserve - Create batch reservations +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const body = await request.json(); + const validatedData = reserveSchema.parse(body); + + const { storeId, items, cartId, ttlMinutes } = validatedData; + + // Verify store membership to prevent cross-tenant access + const { prisma } = await import('@/lib/prisma'); + const membership = await prisma.membership.findFirst({ + where: { + userId: session.user.id, + organization: { + store: { + id: storeId + } + } + } + }); + + if (!membership) { + return NextResponse.json( + { error: 'Forbidden: You do not have access to this store' }, + { status: 403 } + ); + } + + // Create reservations + const reservationService = InventoryReservationService.getInstance(); + const result = await reservationService.createReservations({ + storeId, + items, + cartId, + ttlMinutes, + }); + + if (!result.success && result.reservations.length === 0) { + // All reservations failed + return NextResponse.json( + { + error: 'Reservation failed', + errors: result.errors, + }, + { status: 409 } // Conflict - insufficient stock + ); + } + + return NextResponse.json({ + success: result.success, + reservations: result.reservations, + errors: result.errors.length > 0 ? result.errors : undefined, + message: result.success + ? 'Reservations created successfully' + : 'Some reservations failed', + }); + } catch (error) { + console.error('POST /api/inventory/reserve error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { + error: 'Validation error', + details: error.issues, + }, + { status: 400 } + ); + } + + return NextResponse.json( + { + error: 'Failed to create reservations', + details: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} diff --git a/src/lib/services/checkout.service.ts b/src/lib/services/checkout.service.ts index f644ec97..901787ae 100644 --- a/src/lib/services/checkout.service.ts +++ b/src/lib/services/checkout.service.ts @@ -86,6 +86,7 @@ export interface CreateOrderInput { ipAddress?: string; paymentMethod?: string; paymentGateway?: string; + cartId?: string; // For inventory reservation consumption } /** @@ -348,6 +349,8 @@ export class CheckoutService { // Import InventoryAdjustmentReason for audit logging const { InventoryAdjustmentReason } = await import('./inventory.service'); + // Import InventoryReservationService for consuming reservations + const { InventoryReservationService } = await import('./inventory-reservation.service'); // Create order with items AND deduct inventory in single transaction // CRITICAL: This ensures atomicity - if inventory deduction fails, order is rolled back @@ -376,6 +379,18 @@ export class CheckoutService { }, }); + // If cartId is provided, consume any active reservations + // This prevents double-deduction when reservations were already created + if (input.cartId) { + const reservationService = InventoryReservationService.getInstance(); + await reservationService.consumeReservations( + tx, + input.storeId, + input.cartId, + newOrder.id + ); + } + // Create order items const orderItems = await Promise.all( validated.items.map((item) => diff --git a/src/lib/services/inventory-reservation.service.ts b/src/lib/services/inventory-reservation.service.ts new file mode 100644 index 00000000..a86e99a9 --- /dev/null +++ b/src/lib/services/inventory-reservation.service.ts @@ -0,0 +1,590 @@ +// src/lib/services/inventory-reservation.service.ts +// Inventory Reservation Service +// Handles short-lived inventory reservations to prevent overselling + +import { prisma } from '@/lib/prisma'; +import { Prisma, ReservationStatus } from '@prisma/client'; +import { AuditLogService } from './audit-log.service'; + +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +/** + * Default reservation TTL in minutes + * Can be overridden per reservation + */ +export const DEFAULT_RESERVATION_TTL_MINUTES = 15; + +/** + * Maximum extension time in minutes (once per reservation) + */ +export const MAX_EXTENSION_MINUTES = 15; + +// ============================================================================ +// TYPES AND INTERFACES +// ============================================================================ + +/** + * Item to reserve + */ +export interface ReservationItem { + productId: string; + variantId?: string; + quantity: number; +} + +/** + * Options for creating reservations + */ +export interface CreateReservationOptions { + storeId: string; + items: ReservationItem[]; + cartId?: string; + ttlMinutes?: number; +} + +/** + * Created reservation result + */ +export interface ReservationResult { + id: string; + productId: string; + variantId?: string; + quantity: number; + expiresAt: Date; + status: ReservationStatus; +} + +/** + * Batch reservation result + */ +export interface BatchReservationResult { + success: boolean; + reservations: ReservationResult[]; + errors: Array<{ productId: string; variantId?: string; message: string }>; +} + +/** + * Available stock info + */ +export interface AvailableStockInfo { + productId: string; + variantId?: string; + totalStock: number; + reservedQuantity: number; + availableStock: number; +} + +// ============================================================================ +// INVENTORY RESERVATION SERVICE +// ============================================================================ + +export class InventoryReservationService { + private static instance: InventoryReservationService; + private auditLogService: AuditLogService; + + private constructor() { + this.auditLogService = AuditLogService.getInstance(); + } + + static getInstance(): InventoryReservationService { + if (!InventoryReservationService.instance) { + InventoryReservationService.instance = new InventoryReservationService(); + } + return InventoryReservationService.instance; + } + + // ========================================================================== + // RESERVATION OPERATIONS + // ========================================================================== + + /** + * Create batch reservations for cart items + * Validates stock availability (currentStock - activeReservations >= requested) + * Uses transaction to ensure atomicity + */ + async createReservations( + options: CreateReservationOptions + ): Promise { + const { storeId, items, cartId, ttlMinutes = DEFAULT_RESERVATION_TTL_MINUTES } = options; + + if (items.length === 0) { + return { success: true, reservations: [], errors: [] }; + } + + const errors: Array<{ productId: string; variantId?: string; message: string }> = []; + const reservations: ReservationResult[] = []; + const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000); + + // Use transaction for atomic batch reservation + await prisma.$transaction(async (tx) => { + for (const item of items) { + try { + // Get available stock considering active reservations + const availableStock = await this.getAvailableStockInTransaction( + tx, + storeId, + item.productId, + item.variantId + ); + + if (availableStock.availableStock < item.quantity) { + errors.push({ + productId: item.productId, + variantId: item.variantId, + message: `Insufficient stock. Available: ${availableStock.availableStock}, Requested: ${item.quantity}`, + }); + continue; + } + + // Create reservation + const reservation = await tx.inventoryReservation.create({ + data: { + storeId, + productId: item.productId, + variantId: item.variantId, + quantity: item.quantity, + status: ReservationStatus.ACTIVE, + cartId, + expiresAt, + }, + }); + + reservations.push({ + id: reservation.id, + productId: reservation.productId, + variantId: reservation.variantId ?? undefined, + quantity: reservation.quantity, + expiresAt: reservation.expiresAt, + status: reservation.status, + }); + } catch (error) { + errors.push({ + productId: item.productId, + variantId: item.variantId, + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + // If any errors occurred, throw to rollback transaction + if (errors.length > 0 && reservations.length === 0) { + throw new Error('All reservations failed'); + } + }); + + // Create audit log entries for successful reservations (outside transaction) + for (const reservation of reservations) { + await this.auditLogService.create( + 'CREATE', + 'InventoryReservation', + reservation.id, + { + storeId, + changes: { + action: { old: null, new: 'inventory_reservation_created' }, + productId: { old: null, new: reservation.productId }, + variantId: { old: null, new: reservation.variantId ?? null }, + quantity: { old: null, new: reservation.quantity }, + expiresAt: { old: null, new: reservation.expiresAt.toISOString() }, + cartId: { old: null, new: cartId ?? null }, + }, + } + ); + } + + return { + success: errors.length === 0, + reservations, + errors, + }; + } + + /** + * Release a reservation manually + */ + async releaseReservation( + reservationId: string, + storeId: string + ): Promise { + const reservation = await prisma.inventoryReservation.findFirst({ + where: { + id: reservationId, + storeId, + status: ReservationStatus.ACTIVE, + }, + }); + + if (!reservation) { + return false; + } + + await prisma.inventoryReservation.update({ + where: { id: reservationId }, + data: { status: ReservationStatus.RELEASED }, + }); + + // Create audit log + await this.auditLogService.create( + 'UPDATE', + 'InventoryReservation', + reservationId, + { + storeId, + changes: { + action: { old: 'ACTIVE', new: 'inventory_reservation_released' }, + status: { old: ReservationStatus.ACTIVE, new: ReservationStatus.RELEASED }, + }, + } + ); + + return true; + } + + /** + * Release all reservations for a cart + */ + async releaseCartReservations( + cartId: string, + storeId: string + ): Promise { + const result = await prisma.inventoryReservation.updateMany({ + where: { + cartId, + storeId, + status: ReservationStatus.ACTIVE, + }, + data: { status: ReservationStatus.RELEASED }, + }); + + // Create audit log for batch release + if (result.count > 0) { + await this.auditLogService.create( + 'UPDATE', + 'InventoryReservation', + `cart:${cartId}`, + { + storeId, + changes: { + action: { old: null, new: 'inventory_reservations_released_batch' }, + cartId: { old: cartId, new: cartId }, + count: { old: null, new: result.count }, + }, + } + ); + } + + return result.count; + } + + /** + * Consume reservations when order is created + * Atomically converts reservations to inventory deductions + * CRITICAL: Prevents double decrement by setting status to CONSUMED + */ + async consumeReservations( + tx: Prisma.TransactionClient, + storeId: string, + cartId: string, + orderId: string + ): Promise { + // Get active reservations for this cart + const reservations = await tx.inventoryReservation.findMany({ + where: { + cartId, + storeId, + status: ReservationStatus.ACTIVE, + }, + }); + + if (reservations.length === 0) { + return; + } + + // Update all reservations to CONSUMED with orderId + await tx.inventoryReservation.updateMany({ + where: { + cartId, + storeId, + status: ReservationStatus.ACTIVE, + }, + data: { + status: ReservationStatus.CONSUMED, + orderId, + }, + }); + } + + /** + * Extend a reservation (only allowed once per reservation) + */ + async extendReservation( + reservationId: string, + storeId: string, + extensionMinutes: number = MAX_EXTENSION_MINUTES + ): Promise { + // Validate extension time + if (extensionMinutes > MAX_EXTENSION_MINUTES) { + throw new Error(`Extension time cannot exceed ${MAX_EXTENSION_MINUTES} minutes`); + } + + const reservation = await prisma.inventoryReservation.findFirst({ + where: { + id: reservationId, + storeId, + status: ReservationStatus.ACTIVE, + }, + }); + + if (!reservation) { + return null; + } + + // Check if already extended + if (reservation.extendedAt !== null) { + throw new Error('Reservation can only be extended once'); + } + + // Calculate new expiration time + const newExpiresAt = new Date( + Math.max(reservation.expiresAt.getTime(), Date.now()) + extensionMinutes * 60 * 1000 + ); + + const updated = await prisma.inventoryReservation.update({ + where: { id: reservationId }, + data: { + expiresAt: newExpiresAt, + extendedAt: new Date(), + }, + }); + + // Create audit log + await this.auditLogService.create( + 'UPDATE', + 'InventoryReservation', + reservationId, + { + storeId, + changes: { + action: { old: null, new: 'inventory_reservation_extended' }, + expiresAt: { old: reservation.expiresAt.toISOString(), new: newExpiresAt.toISOString() }, + extensionMinutes: { old: null, new: extensionMinutes }, + }, + } + ); + + return { + id: updated.id, + productId: updated.productId, + variantId: updated.variantId ?? undefined, + quantity: updated.quantity, + expiresAt: updated.expiresAt, + status: updated.status, + }; + } + + // ========================================================================== + // EXPIRATION SWEEPER + // ========================================================================== + + /** + * Expire all reservations past their expiration time + * Should be run every minute via cron job + * Returns count of expired reservations + */ + async expireReservations(): Promise { + const now = new Date(); + + // Get expired reservations before updating (for audit logging) + const expiredReservations = await prisma.inventoryReservation.findMany({ + where: { + status: ReservationStatus.ACTIVE, + expiresAt: { lte: now }, + }, + select: { + id: true, + storeId: true, + productId: true, + variantId: true, + quantity: true, + }, + }); + + if (expiredReservations.length === 0) { + return 0; + } + + // Update all expired reservations + const result = await prisma.inventoryReservation.updateMany({ + where: { + status: ReservationStatus.ACTIVE, + expiresAt: { lte: now }, + }, + data: { status: ReservationStatus.EXPIRED }, + }); + + // Create audit log entries for expired reservations + for (const reservation of expiredReservations) { + await this.auditLogService.create( + 'UPDATE', + 'InventoryReservation', + reservation.id, + { + storeId: reservation.storeId, + changes: { + action: { old: 'ACTIVE', new: 'inventory_reservation_expired' }, + status: { old: ReservationStatus.ACTIVE, new: ReservationStatus.EXPIRED }, + productId: { old: null, new: reservation.productId }, + variantId: { old: null, new: reservation.variantId ?? null }, + quantity: { old: null, new: reservation.quantity }, + }, + } + ); + } + + return result.count; + } + + // ========================================================================== + // STOCK AVAILABILITY QUERIES + // ========================================================================== + + /** + * Get available stock for a product/variant + * Available = Total Stock - Active Reservations + */ + async getAvailableStock( + storeId: string, + productId: string, + variantId?: string + ): Promise { + return this.getAvailableStockInTransaction( + prisma, + storeId, + productId, + variantId + ); + } + + /** + * Internal method to get available stock within a transaction + */ + private async getAvailableStockInTransaction( + tx: Prisma.TransactionClient | typeof prisma, + storeId: string, + productId: string, + variantId?: string + ): Promise { + // Get total stock + let totalStock = 0; + + if (variantId) { + const variant = await tx.productVariant.findUnique({ + where: { id: variantId }, + select: { inventoryQty: true }, + }); + totalStock = variant?.inventoryQty ?? 0; + } else { + const product = await tx.product.findUnique({ + where: { id: productId }, + select: { inventoryQty: true }, + }); + totalStock = product?.inventoryQty ?? 0; + } + + // Get active reservation count + const reservations = await tx.inventoryReservation.aggregate({ + where: { + storeId, + productId, + variantId: variantId ?? null, + status: ReservationStatus.ACTIVE, + expiresAt: { gt: new Date() }, + }, + _sum: { quantity: true }, + }); + + const reservedQuantity = reservations._sum.quantity ?? 0; + const availableStock = Math.max(0, totalStock - reservedQuantity); + + return { + productId, + variantId, + totalStock, + reservedQuantity, + availableStock, + }; + } + + /** + * Get active reservations for a cart + */ + async getCartReservations( + cartId: string, + storeId: string + ): Promise { + const reservations = await prisma.inventoryReservation.findMany({ + where: { + cartId, + storeId, + status: ReservationStatus.ACTIVE, + expiresAt: { gt: new Date() }, + }, + }); + + return reservations.map((r) => ({ + id: r.id, + productId: r.productId, + variantId: r.variantId ?? undefined, + quantity: r.quantity, + expiresAt: r.expiresAt, + status: r.status, + })); + } + + /** + * Get total reserved quantity for a product (for low stock alert accuracy) + */ + async getTotalReservedQuantity( + storeId: string, + productId: string, + variantId?: string + ): Promise { + const result = await prisma.inventoryReservation.aggregate({ + where: { + storeId, + productId, + variantId: variantId ?? null, + status: ReservationStatus.ACTIVE, + expiresAt: { gt: new Date() }, + }, + _sum: { quantity: true }, + }); + + return result._sum.quantity ?? 0; + } + + /** + * Check if reservation exists for a specific product in a cart + */ + async hasReservation( + cartId: string, + storeId: string, + productId: string, + variantId?: string + ): Promise { + const count = await prisma.inventoryReservation.count({ + where: { + cartId, + storeId, + productId, + variantId: variantId ?? null, + status: ReservationStatus.ACTIVE, + expiresAt: { gt: new Date() }, + }, + }); + + return count > 0; + } +} diff --git a/src/lib/services/inventory.service.ts b/src/lib/services/inventory.service.ts index c8dcb9ae..f75ec47c 100644 --- a/src/lib/services/inventory.service.ts +++ b/src/lib/services/inventory.service.ts @@ -4,7 +4,7 @@ import { prisma } from '@/lib/prisma'; import { caseInsensitiveStringFilter } from '@/lib/prisma-utils'; -import { InventoryStatus, Prisma } from '@prisma/client'; +import { InventoryStatus, Prisma, ReservationStatus } from '@prisma/client'; // ============================================================================ // INVENTORY ADJUSTMENT REASON CODES @@ -280,6 +280,7 @@ export class InventoryService { /** * Get products with low stock alerts + * Excludes reserved quantity for accurate alert calculation */ async getLowStockItems(storeId: string, threshold?: number): Promise { const products = await prisma.product.findMany({ @@ -328,30 +329,61 @@ export class InventoryService { ], }); + // Get active reservations for all products + const productIds = products.map(p => p.id); + const reservations = await prisma.inventoryReservation.groupBy({ + by: ['productId', 'variantId'], + where: { + storeId, + productId: { in: productIds }, + status: ReservationStatus.ACTIVE, + expiresAt: { gt: new Date() }, + }, + _sum: { quantity: true }, + }); + + // Create a lookup map for reserved quantities + const reservedQtyMap = new Map(); + for (const r of reservations) { + const key = r.variantId ? `variant:${r.variantId}` : `product:${r.productId}`; + reservedQtyMap.set(key, r._sum.quantity ?? 0); + } + return products.map((product) => { - const lowStockVariants = product.variants.filter( - (v) => v.inventoryQty <= v.lowStockThreshold - ); + // Get reserved quantity for product + const productReserved = reservedQtyMap.get(`product:${product.id}`) ?? 0; + // Available stock = current stock - reserved quantity + const availableQty = product.inventoryQty - productReserved; + + const lowStockVariants = product.variants.filter((v) => { + const variantReserved = reservedQtyMap.get(`variant:${v.id}`) ?? 0; + const variantAvailableQty = v.inventoryQty - variantReserved; + return variantAvailableQty <= v.lowStockThreshold; + }); return { id: product.id, name: product.name, sku: product.sku, - inventoryQty: product.inventoryQty, + inventoryQty: availableQty, // Report available qty (excluding reservations) lowStockThreshold: product.lowStockThreshold, inventoryStatus: product.inventoryStatus, categoryName: product.category?.name, brandName: product.brand?.name, - deficit: Math.max(0, product.lowStockThreshold - product.inventoryQty), + deficit: Math.max(0, product.lowStockThreshold - availableQty), variants: lowStockVariants.length > 0 - ? lowStockVariants.map((v) => ({ - id: v.id, - name: v.name, - sku: v.sku, - inventoryQty: v.inventoryQty, - lowStockThreshold: v.lowStockThreshold, - deficit: Math.max(0, v.lowStockThreshold - v.inventoryQty), - })) + ? lowStockVariants.map((v) => { + const variantReserved = reservedQtyMap.get(`variant:${v.id}`) ?? 0; + const variantAvailableQty = v.inventoryQty - variantReserved; + return { + id: v.id, + name: v.name, + sku: v.sku, + inventoryQty: variantAvailableQty, + lowStockThreshold: v.lowStockThreshold, + deficit: Math.max(0, v.lowStockThreshold - variantAvailableQty), + }; + }) : undefined, }; }); From ad697f619be5bec27aa203fa387b4db707514cda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 03:16:55 +0000 Subject: [PATCH 4/4] Address code review feedback: improve error messages and add performance safeguards Co-authored-by: AshrafAbir <88766326+AshrafAbir@users.noreply.github.com> --- src/lib/services/inventory-reservation.service.ts | 14 ++++++++++---- src/lib/services/inventory.service.ts | 10 ++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/lib/services/inventory-reservation.service.ts b/src/lib/services/inventory-reservation.service.ts index a86e99a9..5fbcb9bc 100644 --- a/src/lib/services/inventory-reservation.service.ts +++ b/src/lib/services/inventory-reservation.service.ts @@ -170,7 +170,10 @@ export class InventoryReservationService { // If any errors occurred, throw to rollback transaction if (errors.length > 0 && reservations.length === 0) { - throw new Error('All reservations failed'); + const errorSummary = errors.map(e => + `${e.productId}${e.variantId ? `/${e.variantId}` : ''}: ${e.message}` + ).join('; '); + throw new Error(`All reservations failed: ${errorSummary}`); } }); @@ -347,9 +350,12 @@ export class InventoryReservationService { } // Calculate new expiration time - const newExpiresAt = new Date( - Math.max(reservation.expiresAt.getTime(), Date.now()) + extensionMinutes * 60 * 1000 - ); + // Use Math.max to handle the edge case where reservation hasn't expired yet but is close + // to expiring. This ensures the extension always adds time from the later of: + // - the current expiration time (if not yet expired), or + // - the current time (if already expired, we extend from now) + const baseTime = Math.max(reservation.expiresAt.getTime(), Date.now()); + const newExpiresAt = new Date(baseTime + extensionMinutes * 60 * 1000); const updated = await prisma.inventoryReservation.update({ where: { id: reservationId }, diff --git a/src/lib/services/inventory.service.ts b/src/lib/services/inventory.service.ts index f75ec47c..e8a12b3f 100644 --- a/src/lib/services/inventory.service.ts +++ b/src/lib/services/inventory.service.ts @@ -327,10 +327,20 @@ export class InventoryService { { inventoryStatus: 'asc' }, { inventoryQty: 'asc' }, ], + // Limit to reasonable number to prevent memory issues on large stores + take: 500, }); // Get active reservations for all products + // Note: This query is scoped to low/out-of-stock products only (typically a small subset) + // The take: 500 limit above ensures this doesn't cause performance issues const productIds = products.map(p => p.id); + + // Skip reservation query if no products found + if (productIds.length === 0) { + return []; + } + const reservations = await prisma.inventoryReservation.groupBy({ by: ['productId', 'variantId'], where: {