Add complete POS (Point of Sale) backend: - Sales API: list with filters, create with atomicity (stock updates), void/cancel - Cash register API: open/close registers, get details with payment breakdown - Transactions API: list transactions, add manual deposits/withdrawals Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
458 lines
12 KiB
TypeScript
458 lines
12 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|