diff --git a/apps/web/app/api/cash-register/[id]/route.ts b/apps/web/app/api/cash-register/[id]/route.ts new file mode 100644 index 0000000..7d0a6d9 --- /dev/null +++ b/apps/web/app/api/cash-register/[id]/route.ts @@ -0,0 +1,308 @@ +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'; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +// Validation schema for closing a cash register +const closeRegisterSchema = z.object({ + closingAmount: z.number().nonnegative('Closing amount must be non-negative'), + notes: z.string().max(500).optional(), +}); + +// GET /api/cash-register/[id] - Get register details with transactions and summary +export async function GET( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { id } = await context.params; + + const cashRegister = await db.cashRegister.findFirst({ + where: { + id, + site: { + organizationId: session.user.organizationId, + }, + }, + include: { + site: { + select: { + id: true, + name: true, + address: true, + }, + }, + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + sales: { + include: { + items: { + include: { + product: { + select: { + id: true, + name: true, + }, + }, + }, + }, + client: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }, + payments: { + select: { + id: true, + amount: true, + paymentType: true, + reference: true, + notes: true, + createdAt: true, + booking: { + select: { + id: true, + }, + }, + sale: { + select: { + id: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }, + }, + }); + + if (!cashRegister) { + return NextResponse.json( + { error: 'Cash register not found' }, + { status: 404 } + ); + } + + // Calculate payment breakdown by method + const paymentBreakdown = cashRegister.payments.reduce((acc, payment) => { + const type = payment.paymentType; + acc[type] = (acc[type] || 0) + Number(payment.amount); + return acc; + }, {} as Record); + + // Calculate totals + const totalCashSales = paymentBreakdown['CASH'] || 0; + const totalCardSales = paymentBreakdown['CARD'] || 0; + const totalTransferSales = paymentBreakdown['TRANSFER'] || 0; + const totalMembershipSales = paymentBreakdown['MEMBERSHIP'] || 0; + const totalFreeSales = paymentBreakdown['FREE'] || 0; + + const totalAllPayments = Object.values(paymentBreakdown).reduce((sum, val) => sum + val, 0); + const expectedCashAmount = Number(cashRegister.openingAmount) + totalCashSales; + + // Calculate difference if register is closed + let actualDifference = null; + if (cashRegister.closedAt && cashRegister.closingAmount !== null) { + actualDifference = Number(cashRegister.closingAmount) - expectedCashAmount; + } + + return NextResponse.json({ + ...cashRegister, + summary: { + paymentBreakdown, + totalCashSales, + totalCardSales, + totalTransferSales, + totalMembershipSales, + totalFreeSales, + totalAllPayments, + openingAmount: Number(cashRegister.openingAmount), + expectedCashAmount, + closingAmount: cashRegister.closingAmount ? Number(cashRegister.closingAmount) : null, + difference: actualDifference, + salesCount: cashRegister.sales.length, + transactionsCount: cashRegister.payments.length, + }, + }); + } catch (error) { + console.error('Error fetching cash register:', error); + return NextResponse.json( + { error: 'Failed to fetch cash register' }, + { status: 500 } + ); + } +} + +// PUT /api/cash-register/[id] - Close a cash register +export async function PUT( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { id } = await context.params; + + // Fetch the cash register + const existingRegister = await db.cashRegister.findFirst({ + where: { + id, + site: { + organizationId: session.user.organizationId, + }, + }, + }); + + if (!existingRegister) { + return NextResponse.json( + { error: 'Cash register not found' }, + { status: 404 } + ); + } + + // Check if already closed + if (existingRegister.closedAt) { + return NextResponse.json( + { error: 'Cash register is already closed' }, + { status: 400 } + ); + } + + // If user is SITE_ADMIN, verify they have access to this site + if (session.user.role === 'SITE_ADMIN' && session.user.siteId !== existingRegister.siteId) { + return NextResponse.json( + { error: 'Forbidden: You do not have access to this cash register' }, + { status: 403 } + ); + } + + const body = await request.json(); + + // Validate input with Zod schema + const validationResult = closeRegisterSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid close register data', + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { closingAmount, notes } = validationResult.data; + + // Calculate expected amount from cash payments + const cashPayments = await db.payment.aggregate({ + where: { + cashRegisterId: id, + paymentType: 'CASH', + }, + _sum: { + amount: true, + }, + }); + + const totalCashPayments = cashPayments._sum.amount ? Number(cashPayments._sum.amount) : 0; + const expectedAmount = Number(existingRegister.openingAmount) + totalCashPayments; + const difference = closingAmount - expectedAmount; + + // Close the cash register + const cashRegister = await db.cashRegister.update({ + where: { id }, + data: { + closedAt: new Date(), + closingAmount: new Decimal(closingAmount), + expectedAmount: new Decimal(expectedAmount), + difference: new Decimal(difference), + notes: notes || null, + }, + include: { + site: { + select: { + id: true, + name: true, + }, + }, + user: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + }, + }); + + // Get payment breakdown for the response + const allPayments = await db.payment.findMany({ + where: { + cashRegisterId: id, + }, + select: { + amount: true, + paymentType: true, + }, + }); + + const paymentBreakdown = allPayments.reduce((acc, payment) => { + const type = payment.paymentType; + acc[type] = (acc[type] || 0) + Number(payment.amount); + return acc; + }, {} as Record); + + return NextResponse.json({ + ...cashRegister, + summary: { + paymentBreakdown, + openingAmount: Number(existingRegister.openingAmount), + expectedAmount, + closingAmount, + difference, + status: difference === 0 ? 'balanced' : difference > 0 ? 'over' : 'short', + }, + }); + } catch (error) { + console.error('Error closing cash register:', error); + return NextResponse.json( + { error: 'Failed to close cash register' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/cash-register/[id]/transactions/route.ts b/apps/web/app/api/cash-register/[id]/transactions/route.ts new file mode 100644 index 0000000..f3ffa65 --- /dev/null +++ b/apps/web/app/api/cash-register/[id]/transactions/route.ts @@ -0,0 +1,301 @@ +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'; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +// Validation schema for manual transaction +const manualTransactionSchema = z.object({ + type: z.enum(['DEPOSIT', 'WITHDRAWAL']), + amount: z.number().positive('Amount must be positive'), + reason: z.string().min(1, 'Reason is required').max(500), +}); + +// GET /api/cash-register/[id]/transactions - List all transactions for this register +export async function GET( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { id } = await context.params; + + // Verify the cash register exists and belongs to user's organization + const cashRegister = await db.cashRegister.findFirst({ + where: { + id, + site: { + organizationId: session.user.organizationId, + }, + }, + }); + + if (!cashRegister) { + return NextResponse.json( + { error: 'Cash register not found' }, + { status: 404 } + ); + } + + // Get all payments (transactions) for this register + const transactions = await db.payment.findMany({ + where: { + cashRegisterId: id, + }, + include: { + booking: { + select: { + id: true, + startTime: true, + endTime: true, + court: { + select: { + name: true, + }, + }, + client: { + select: { + firstName: true, + lastName: true, + }, + }, + }, + }, + sale: { + select: { + id: true, + total: true, + items: { + select: { + product: { + select: { + name: true, + }, + }, + quantity: true, + }, + }, + }, + }, + client: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + // Categorize transactions + const categorizedTransactions = transactions.map(transaction => { + let category: string; + let description: string; + + if (transaction.booking) { + category = 'BOOKING'; + const client = transaction.booking.client; + description = `Booking payment${client ? ` - ${client.firstName} ${client.lastName}` : ''} - ${transaction.booking.court?.name || 'Unknown court'}`; + } else if (transaction.sale) { + category = 'SALE'; + const itemCount = transaction.sale.items.reduce((sum, item) => sum + item.quantity, 0); + description = `Sale payment - ${itemCount} item(s)`; + } else if (transaction.notes?.startsWith('DEPOSIT:') || transaction.notes?.startsWith('WITHDRAWAL:')) { + category = transaction.notes.startsWith('DEPOSIT:') ? 'DEPOSIT' : 'WITHDRAWAL'; + description = transaction.notes.substring(transaction.notes.indexOf(':') + 1).trim(); + } else { + category = 'OTHER'; + description = transaction.notes || 'Unknown transaction'; + } + + return { + id: transaction.id, + amount: transaction.amount, + paymentType: transaction.paymentType, + reference: transaction.reference, + category, + description, + createdAt: transaction.createdAt, + bookingId: transaction.booking?.id || null, + saleId: transaction.sale?.id || null, + client: transaction.client, + }; + }); + + // Calculate summary + const summary = { + totalDeposits: categorizedTransactions + .filter(t => t.category === 'DEPOSIT') + .reduce((sum, t) => sum + Number(t.amount), 0), + totalWithdrawals: categorizedTransactions + .filter(t => t.category === 'WITHDRAWAL') + .reduce((sum, t) => sum + Number(t.amount), 0), + totalBookingPayments: categorizedTransactions + .filter(t => t.category === 'BOOKING') + .reduce((sum, t) => sum + Number(t.amount), 0), + totalSalePayments: categorizedTransactions + .filter(t => t.category === 'SALE') + .reduce((sum, t) => sum + Number(t.amount), 0), + totalOther: categorizedTransactions + .filter(t => t.category === 'OTHER') + .reduce((sum, t) => sum + Number(t.amount), 0), + transactionCount: categorizedTransactions.length, + }; + + return NextResponse.json({ + transactions: categorizedTransactions, + summary, + }); + } catch (error) { + console.error('Error fetching transactions:', error); + return NextResponse.json( + { error: 'Failed to fetch transactions' }, + { status: 500 } + ); + } +} + +// POST /api/cash-register/[id]/transactions - Add manual transaction (deposit/withdrawal) +export async function POST( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Check if user has appropriate role + const allowedRoles = ['SUPER_ADMIN', 'ORG_ADMIN', 'SITE_ADMIN', 'RECEPTIONIST']; + if (!allowedRoles.includes(session.user.role)) { + return NextResponse.json( + { error: 'Forbidden: Insufficient permissions' }, + { status: 403 } + ); + } + + const { id } = await context.params; + + // Verify the cash register exists and belongs to user's organization + const cashRegister = await db.cashRegister.findFirst({ + where: { + id, + site: { + organizationId: session.user.organizationId, + }, + }, + }); + + if (!cashRegister) { + return NextResponse.json( + { error: 'Cash register not found' }, + { status: 404 } + ); + } + + // Check if register is already closed + if (cashRegister.closedAt) { + return NextResponse.json( + { error: 'Cannot add transactions to a closed cash register' }, + { status: 400 } + ); + } + + // If user is SITE_ADMIN or RECEPTIONIST, verify they have access to this site + if (['SITE_ADMIN', 'RECEPTIONIST'].includes(session.user.role) && session.user.siteId !== cashRegister.siteId) { + return NextResponse.json( + { error: 'Forbidden: You do not have access to this cash register' }, + { status: 403 } + ); + } + + const body = await request.json(); + + // Validate input with Zod schema + const validationResult = manualTransactionSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid transaction data', + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { type, amount, reason } = validationResult.data; + + // For withdrawals, verify there's enough cash in the register + if (type === 'WITHDRAWAL') { + // Get current cash balance + const cashPayments = await db.payment.aggregate({ + where: { + cashRegisterId: id, + paymentType: 'CASH', + }, + _sum: { + amount: true, + }, + }); + + const totalCashPayments = cashPayments._sum.amount ? Number(cashPayments._sum.amount) : 0; + const currentCashBalance = Number(cashRegister.openingAmount) + totalCashPayments; + + if (amount > currentCashBalance) { + return NextResponse.json( + { error: `Insufficient cash balance. Available: ${currentCashBalance.toFixed(2)}, Requested: ${amount.toFixed(2)}` }, + { status: 400 } + ); + } + } + + // Create the payment record + // For deposits, amount is positive; for withdrawals, amount is negative in the payment record + const paymentAmount = type === 'WITHDRAWAL' ? -amount : amount; + + const transaction = await db.payment.create({ + data: { + amount: new Decimal(paymentAmount), + paymentType: 'CASH', + notes: `${type}: ${reason}`, + cashRegisterId: id, + }, + }); + + return NextResponse.json({ + id: transaction.id, + type, + amount: Math.abs(Number(transaction.amount)), + reason, + createdAt: transaction.createdAt, + message: `${type.toLowerCase()} of ${amount.toFixed(2)} recorded successfully`, + }, { status: 201 }); + } catch (error) { + console.error('Error creating transaction:', error); + return NextResponse.json( + { error: 'Failed to create transaction' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/cash-register/route.ts b/apps/web/app/api/cash-register/route.ts new file mode 100644 index 0000000..c42d7bb --- /dev/null +++ b/apps/web/app/api/cash-register/route.ts @@ -0,0 +1,277 @@ +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 opening a cash register +const openRegisterSchema = z.object({ + siteId: z.string().cuid('Invalid site ID'), + openingAmount: z.number().nonnegative('Opening amount must be non-negative'), +}); + +// GET /api/cash-register - Get current open register for site or list all for date +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 date = searchParams.get('date'); + const status = searchParams.get('status'); // 'open', 'closed', or 'all' + + // Build the where clause + interface RegisterWhereClause { + site: { + organizationId: string; + id?: string; + }; + closedAt?: null | { not: null } | { gte?: Date; lte?: Date }; + openedAt?: { + gte?: Date; + lte?: Date; + }; + } + + const whereClause: RegisterWhereClause = { + site: { + organizationId: session.user.organizationId, + }, + }; + + // Filter by site + const effectiveSiteId = siteId || session.user.siteId; + if (effectiveSiteId) { + // Verify site belongs to user's organization + const site = await db.site.findFirst({ + where: { + id: effectiveSiteId, + organizationId: session.user.organizationId, + }, + }); + + if (!site) { + return NextResponse.json( + { error: 'Site not found or does not belong to your organization' }, + { status: 404 } + ); + } + + whereClause.site.id = effectiveSiteId; + } + + // Filter by status + if (status === 'open') { + whereClause.closedAt = null; + } else if (status === 'closed') { + whereClause.closedAt = { not: null }; + } + + // Filter by date + if (date) { + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (dateRegex.test(date)) { + const startOfDay = new Date(date); + startOfDay.setHours(0, 0, 0, 0); + const endOfDay = new Date(date); + endOfDay.setHours(23, 59, 59, 999); + + whereClause.openedAt = { + gte: startOfDay, + lte: endOfDay, + }; + } + } + + const registers = await db.cashRegister.findMany({ + where: whereClause, + include: { + site: { + select: { + id: true, + name: true, + }, + }, + user: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + _count: { + select: { + sales: true, + payments: true, + }, + }, + }, + orderBy: { + openedAt: 'desc', + }, + }); + + // If looking for current open register, return just that one + if (status === 'open' && !date) { + const openRegister = registers[0] || null; + + if (openRegister) { + // Get payment breakdown for the open register + const payments = await db.payment.findMany({ + where: { + cashRegisterId: openRegister.id, + }, + select: { + amount: true, + paymentType: true, + }, + }); + + const paymentBreakdown = payments.reduce((acc, payment) => { + const type = payment.paymentType; + acc[type] = (acc[type] || 0) + Number(payment.amount); + return acc; + }, {} as Record); + + const totalCashSales = paymentBreakdown['CASH'] || 0; + const expectedAmount = Number(openRegister.openingAmount) + totalCashSales; + + return NextResponse.json({ + ...openRegister, + paymentBreakdown, + totalCashSales, + expectedAmount, + }); + } + + return NextResponse.json(null); + } + + return NextResponse.json(registers); + } catch (error) { + console.error('Error fetching cash registers:', error); + return NextResponse.json( + { error: 'Failed to fetch cash registers' }, + { status: 500 } + ); + } +} + +// POST /api/cash-register - Open a new cash register +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 = openRegisterSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid register data', + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { siteId, openingAmount } = 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 } + ); + } + + // Check if there's already an open register for this site + const existingOpenRegister = await db.cashRegister.findFirst({ + where: { + siteId, + closedAt: null, + }, + include: { + user: { + select: { + firstName: true, + lastName: true, + }, + }, + }, + }); + + if (existingOpenRegister) { + return NextResponse.json( + { + error: `There is already an open cash register for this site, opened by ${existingOpenRegister.user.firstName} ${existingOpenRegister.user.lastName}`, + existingRegisterId: existingOpenRegister.id, + }, + { status: 409 } + ); + } + + // Create new cash register + const cashRegister = await db.cashRegister.create({ + data: { + siteId, + userId: session.user.id, + openingAmount: new Decimal(openingAmount), + }, + include: { + site: { + select: { + id: true, + name: true, + }, + }, + user: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + }, + }); + + return NextResponse.json(cashRegister, { status: 201 }); + } catch (error) { + console.error('Error opening cash register:', error); + return NextResponse.json( + { error: 'Failed to open cash register' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/sales/[id]/route.ts b/apps/web/app/api/sales/[id]/route.ts new file mode 100644 index 0000000..56a2e45 --- /dev/null +++ b/apps/web/app/api/sales/[id]/route.ts @@ -0,0 +1,238 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { db } from '@/lib/db'; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +// GET /api/sales/[id] - Get a single sale with items, payments, and products +export async function GET( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { id } = await context.params; + + const sale = await db.sale.findFirst({ + where: { + id, + createdBy: { + organizationId: session.user.organizationId, + }, + }, + include: { + items: { + include: { + product: { + select: { + id: true, + name: true, + sku: true, + price: true, + image: true, + category: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }, + payments: { + select: { + id: true, + amount: true, + paymentType: true, + reference: true, + notes: true, + createdAt: true, + }, + }, + client: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + }, + }, + createdBy: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + cashRegister: { + select: { + id: true, + openedAt: true, + closedAt: true, + site: { + select: { + id: true, + name: true, + }, + }, + user: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + }, + }, + }, + }); + + if (!sale) { + return NextResponse.json( + { error: 'Sale not found' }, + { status: 404 } + ); + } + + return NextResponse.json(sale); + } catch (error) { + console.error('Error fetching sale:', error); + return NextResponse.json( + { error: 'Failed to fetch sale' }, + { status: 500 } + ); + } +} + +// DELETE /api/sales/[id] - Void/cancel a sale and restore stock +export async function DELETE( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Check if user has admin role + const allowedRoles = ['SUPER_ADMIN', 'ORG_ADMIN', 'SITE_ADMIN']; + if (!allowedRoles.includes(session.user.role)) { + return NextResponse.json( + { error: 'Forbidden: Insufficient permissions to void sales' }, + { status: 403 } + ); + } + + const { id } = await context.params; + + // Fetch the sale with items + const sale = await db.sale.findFirst({ + where: { + id, + createdBy: { + organizationId: session.user.organizationId, + }, + }, + include: { + items: { + include: { + product: true, + }, + }, + cashRegister: true, + }, + }); + + if (!sale) { + return NextResponse.json( + { error: 'Sale not found' }, + { status: 404 } + ); + } + + // If sale is associated with a closed cash register, we cannot void it + if (sale.cashRegister && sale.cashRegister.closedAt) { + return NextResponse.json( + { error: 'Cannot void a sale from a closed cash register' }, + { status: 400 } + ); + } + + // If user is SITE_ADMIN, verify they have access to the cash register's site + if (session.user.role === 'SITE_ADMIN' && sale.cashRegister) { + if (session.user.siteId !== sale.cashRegister.siteId) { + return NextResponse.json( + { error: 'Forbidden: You do not have access to void this sale' }, + { status: 403 } + ); + } + } + + // Void the sale within a transaction + await db.$transaction(async (tx) => { + // Restore stock for each item + for (const item of sale.items) { + if (item.product.trackStock) { + await tx.product.update({ + where: { id: item.productId }, + data: { + stock: { + increment: item.quantity, + }, + }, + }); + } + } + + // Delete payments associated with this sale + await tx.payment.deleteMany({ + where: { saleId: id }, + }); + + // Delete sale items + await tx.saleItem.deleteMany({ + where: { saleId: id }, + }); + + // Delete the sale + await tx.sale.delete({ + where: { id }, + }); + }); + + return NextResponse.json({ + message: 'Sale voided successfully', + restoredItems: sale.items.map(item => ({ + productId: item.productId, + productName: item.product.name, + quantity: item.quantity, + stockRestored: item.product.trackStock, + })), + }); + } catch (error) { + console.error('Error voiding sale:', error); + return NextResponse.json( + { error: 'Failed to void sale' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/sales/route.ts b/apps/web/app/api/sales/route.ts new file mode 100644 index 0000000..23e8ed5 --- /dev/null +++ b/apps/web/app/api/sales/route.ts @@ -0,0 +1,457 @@ +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 } + ); + } +}