feat(api): add sales and cash register endpoints
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>
This commit is contained in:
457
apps/web/app/api/sales/route.ts
Normal file
457
apps/web/app/api/sales/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user