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>
278 lines
7.1 KiB
TypeScript
278 lines
7.1 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 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<string, number>);
|
|
|
|
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 }
|
|
);
|
|
}
|
|
}
|