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:
277
apps/web/app/api/cash-register/route.ts
Normal file
277
apps/web/app/api/cash-register/route.ts
Normal file
@@ -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<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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user