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:
Ivan
2026-02-01 06:58:52 +00:00
parent c298b23b35
commit cca3b50a6d
5 changed files with 1581 additions and 0 deletions

View File

@@ -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<string, number>);
// 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<string, number>);
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 }
);
}
}