import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { db } from '@/lib/db'; import { z } from 'zod'; import { Decimal } from '@prisma/client/runtime/library'; // Validation schema for sale item const saleItemSchema = z.object({ productId: z.string().cuid('Invalid product ID'), quantity: z.number().int().positive('Quantity must be positive'), price: z.number().nonnegative('Price must be non-negative'), }); // Validation schema for payment const paymentSchema = z.object({ amount: z.number().positive('Payment amount must be positive'), method: z.enum(['CASH', 'CARD', 'TRANSFER', 'MEMBERSHIP', 'FREE']), reference: z.string().optional(), }); // Validation schema for creating a sale const createSaleSchema = z.object({ siteId: z.string().cuid('Invalid site ID'), clientId: z.string().cuid('Invalid client ID').optional(), items: z.array(saleItemSchema).min(1, 'At least one item is required'), payments: z.array(paymentSchema).min(1, 'At least one payment is required'), notes: z.string().max(500).optional(), discount: z.number().nonnegative('Discount must be non-negative').optional().default(0), tax: z.number().nonnegative('Tax must be non-negative').optional().default(0), }); // GET /api/sales - List sales with filters export async function GET(request: NextRequest) { try { const session = await getServerSession(authOptions); if (!session?.user) { return NextResponse.json( { error: 'Unauthorized' }, { status: 401 } ); } const { searchParams } = new URL(request.url); const siteId = searchParams.get('siteId'); const createdById = searchParams.get('createdBy'); const startDate = searchParams.get('startDate'); const endDate = searchParams.get('endDate'); const clientId = searchParams.get('clientId'); // Build the where clause interface SaleWhereClause { createdBy: { organizationId: string; }; createdById?: string; clientId?: string; cashRegister?: { siteId: string; }; createdAt?: { gte?: Date; lte?: Date; }; } const whereClause: SaleWhereClause = { createdBy: { organizationId: session.user.organizationId, }, }; // Filter by site via cash register if (siteId) { // Verify site belongs to user's organization const site = await db.site.findFirst({ where: { id: siteId, organizationId: session.user.organizationId, }, }); if (!site) { return NextResponse.json( { error: 'Site not found or does not belong to your organization' }, { status: 404 } ); } whereClause.cashRegister = { siteId, }; } else if (session.user.siteId) { whereClause.cashRegister = { siteId: session.user.siteId, }; } // Filter by creator if (createdById) { whereClause.createdById = createdById; } // Filter by client if (clientId) { whereClause.clientId = clientId; } // Filter by date range if (startDate || endDate) { whereClause.createdAt = {}; if (startDate) { const start = new Date(startDate); start.setHours(0, 0, 0, 0); whereClause.createdAt.gte = start; } if (endDate) { const end = new Date(endDate); end.setHours(23, 59, 59, 999); whereClause.createdAt.lte = end; } } const sales = await db.sale.findMany({ where: whereClause, include: { items: { include: { product: { select: { id: true, name: true, sku: true, }, }, }, }, payments: { select: { id: true, amount: true, paymentType: true, reference: true, createdAt: true, }, }, client: { select: { id: true, firstName: true, lastName: true, email: true, phone: true, }, }, createdBy: { select: { id: true, firstName: true, lastName: true, }, }, cashRegister: { select: { id: true, site: { select: { id: true, name: true, }, }, }, }, }, orderBy: { createdAt: 'desc', }, }); return NextResponse.json(sales); } catch (error) { console.error('Error fetching sales:', error); return NextResponse.json( { error: 'Failed to fetch sales' }, { status: 500 } ); } } // POST /api/sales - Create a new sale 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(); // Validate input with Zod schema const validationResult = createSaleSchema.safeParse(body); if (!validationResult.success) { return NextResponse.json( { error: 'Invalid sale data', details: validationResult.error.flatten().fieldErrors, }, { status: 400 } ); } const { siteId, clientId, items, payments, notes, discount, tax } = validationResult.data; // Verify site belongs to user's organization const site = await db.site.findFirst({ where: { id: siteId, organizationId: session.user.organizationId, }, }); if (!site) { return NextResponse.json( { error: 'Site not found or does not belong to your organization' }, { status: 404 } ); } // If user is SITE_ADMIN, verify they have access to this site if (session.user.role === 'SITE_ADMIN' && session.user.siteId !== siteId) { return NextResponse.json( { error: 'Forbidden: You do not have access to this site' }, { status: 403 } ); } // Verify client if provided if (clientId) { const client = await db.client.findFirst({ where: { id: clientId, organizationId: session.user.organizationId, }, }); if (!client) { return NextResponse.json( { error: 'Client not found or does not belong to your organization' }, { status: 404 } ); } } // Calculate subtotal from items const subtotal = items.reduce((sum, item) => sum + (item.price * item.quantity), 0); const total = subtotal - (discount || 0) + (tax || 0); // Validate total matches sum of items if (total < 0) { return NextResponse.json( { error: 'Invalid total: discount cannot exceed subtotal plus tax' }, { status: 400 } ); } // Validate payment covers total const totalPayment = payments.reduce((sum, payment) => sum + payment.amount, 0); if (Math.abs(totalPayment - total) > 0.01) { // Allow for small floating point differences return NextResponse.json( { error: `Payment amount (${totalPayment.toFixed(2)}) does not match total (${total.toFixed(2)})` }, { status: 400 } ); } // Get product IDs and verify stock availability const productIds = items.map(item => item.productId); const products = await db.product.findMany({ where: { id: { in: productIds }, organizationId: session.user.organizationId, isActive: true, }, }); if (products.length !== productIds.length) { return NextResponse.json( { error: 'One or more products not found or inactive' }, { status: 404 } ); } // Check stock availability for products that track stock for (const item of items) { const product = products.find(p => p.id === item.productId); if (product && product.trackStock && product.stock < item.quantity) { return NextResponse.json( { error: `Insufficient stock for product "${product.name}". Available: ${product.stock}, Requested: ${item.quantity}` }, { status: 400 } ); } } // Check for open cash register if any payment is cash const hasCashPayment = payments.some(p => p.method === 'CASH'); let cashRegisterId: string | null = null; if (hasCashPayment) { const openRegister = await db.cashRegister.findFirst({ where: { siteId, closedAt: null, }, }); if (!openRegister) { return NextResponse.json( { error: 'No open cash register found for this site. Please open a register first.' }, { status: 400 } ); } cashRegisterId = openRegister.id; } // Determine primary payment type const primaryPayment = payments.reduce((prev, current) => current.amount > prev.amount ? current : prev ); // Create sale with transaction for atomicity const sale = await db.$transaction(async (tx) => { // Create the sale const newSale = await tx.sale.create({ data: { clientId: clientId || null, createdById: session.user.id, cashRegisterId, subtotal: new Decimal(subtotal), discount: new Decimal(discount || 0), tax: new Decimal(tax || 0), total: new Decimal(total), paymentType: primaryPayment.method, notes: notes || null, }, }); // Create sale items for (const item of items) { await tx.saleItem.create({ data: { saleId: newSale.id, productId: item.productId, quantity: item.quantity, unitPrice: new Decimal(item.price), subtotal: new Decimal(item.price * item.quantity), }, }); // Update product stock (decrement) const product = products.find(p => p.id === item.productId); if (product && product.trackStock) { await tx.product.update({ where: { id: item.productId }, data: { stock: { decrement: item.quantity, }, }, }); } } // Create payments for (const payment of payments) { await tx.payment.create({ data: { saleId: newSale.id, clientId: clientId || null, amount: new Decimal(payment.amount), paymentType: payment.method, reference: payment.reference || null, cashRegisterId: payment.method === 'CASH' ? cashRegisterId : null, }, }); } // Return the sale with all relations return tx.sale.findUnique({ where: { id: newSale.id }, include: { items: { include: { product: { select: { id: true, name: true, sku: true, }, }, }, }, payments: { select: { id: true, amount: true, paymentType: true, reference: true, createdAt: true, }, }, client: { select: { id: true, firstName: true, lastName: true, email: true, phone: true, }, }, createdBy: { select: { id: true, firstName: true, lastName: true, }, }, cashRegister: { select: { id: true, site: { select: { id: true, name: true, }, }, }, }, }, }); }); return NextResponse.json(sale, { status: 201 }); } catch (error) { console.error('Error creating sale:', error); return NextResponse.json( { error: 'Failed to create sale' }, { status: 500 } ); } }