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>
302 lines
8.7 KiB
TypeScript
302 lines
8.7 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';
|
|
|
|
interface RouteContext {
|
|
params: Promise<{ id: string }>;
|
|
}
|
|
|
|
// Validation schema for manual transaction
|
|
const manualTransactionSchema = z.object({
|
|
type: z.enum(['DEPOSIT', 'WITHDRAWAL']),
|
|
amount: z.number().positive('Amount must be positive'),
|
|
reason: z.string().min(1, 'Reason is required').max(500),
|
|
});
|
|
|
|
// GET /api/cash-register/[id]/transactions - List all transactions for this register
|
|
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;
|
|
|
|
// Verify the cash register exists and belongs to user's organization
|
|
const cashRegister = await db.cashRegister.findFirst({
|
|
where: {
|
|
id,
|
|
site: {
|
|
organizationId: session.user.organizationId,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!cashRegister) {
|
|
return NextResponse.json(
|
|
{ error: 'Cash register not found' },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
// Get all payments (transactions) for this register
|
|
const transactions = await db.payment.findMany({
|
|
where: {
|
|
cashRegisterId: id,
|
|
},
|
|
include: {
|
|
booking: {
|
|
select: {
|
|
id: true,
|
|
startTime: true,
|
|
endTime: true,
|
|
court: {
|
|
select: {
|
|
name: true,
|
|
},
|
|
},
|
|
client: {
|
|
select: {
|
|
firstName: true,
|
|
lastName: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
sale: {
|
|
select: {
|
|
id: true,
|
|
total: true,
|
|
items: {
|
|
select: {
|
|
product: {
|
|
select: {
|
|
name: true,
|
|
},
|
|
},
|
|
quantity: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
client: {
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
createdAt: 'desc',
|
|
},
|
|
});
|
|
|
|
// Categorize transactions
|
|
const categorizedTransactions = transactions.map(transaction => {
|
|
let category: string;
|
|
let description: string;
|
|
|
|
if (transaction.booking) {
|
|
category = 'BOOKING';
|
|
const client = transaction.booking.client;
|
|
description = `Booking payment${client ? ` - ${client.firstName} ${client.lastName}` : ''} - ${transaction.booking.court?.name || 'Unknown court'}`;
|
|
} else if (transaction.sale) {
|
|
category = 'SALE';
|
|
const itemCount = transaction.sale.items.reduce((sum, item) => sum + item.quantity, 0);
|
|
description = `Sale payment - ${itemCount} item(s)`;
|
|
} else if (transaction.notes?.startsWith('DEPOSIT:') || transaction.notes?.startsWith('WITHDRAWAL:')) {
|
|
category = transaction.notes.startsWith('DEPOSIT:') ? 'DEPOSIT' : 'WITHDRAWAL';
|
|
description = transaction.notes.substring(transaction.notes.indexOf(':') + 1).trim();
|
|
} else {
|
|
category = 'OTHER';
|
|
description = transaction.notes || 'Unknown transaction';
|
|
}
|
|
|
|
return {
|
|
id: transaction.id,
|
|
amount: transaction.amount,
|
|
paymentType: transaction.paymentType,
|
|
reference: transaction.reference,
|
|
category,
|
|
description,
|
|
createdAt: transaction.createdAt,
|
|
bookingId: transaction.booking?.id || null,
|
|
saleId: transaction.sale?.id || null,
|
|
client: transaction.client,
|
|
};
|
|
});
|
|
|
|
// Calculate summary
|
|
const summary = {
|
|
totalDeposits: categorizedTransactions
|
|
.filter(t => t.category === 'DEPOSIT')
|
|
.reduce((sum, t) => sum + Number(t.amount), 0),
|
|
totalWithdrawals: categorizedTransactions
|
|
.filter(t => t.category === 'WITHDRAWAL')
|
|
.reduce((sum, t) => sum + Number(t.amount), 0),
|
|
totalBookingPayments: categorizedTransactions
|
|
.filter(t => t.category === 'BOOKING')
|
|
.reduce((sum, t) => sum + Number(t.amount), 0),
|
|
totalSalePayments: categorizedTransactions
|
|
.filter(t => t.category === 'SALE')
|
|
.reduce((sum, t) => sum + Number(t.amount), 0),
|
|
totalOther: categorizedTransactions
|
|
.filter(t => t.category === 'OTHER')
|
|
.reduce((sum, t) => sum + Number(t.amount), 0),
|
|
transactionCount: categorizedTransactions.length,
|
|
};
|
|
|
|
return NextResponse.json({
|
|
transactions: categorizedTransactions,
|
|
summary,
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching transactions:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Failed to fetch transactions' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// POST /api/cash-register/[id]/transactions - Add manual transaction (deposit/withdrawal)
|
|
export async function POST(
|
|
request: NextRequest,
|
|
context: RouteContext
|
|
) {
|
|
try {
|
|
const session = await getServerSession(authOptions);
|
|
|
|
if (!session?.user) {
|
|
return NextResponse.json(
|
|
{ error: 'Unauthorized' },
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
// Check if user has appropriate role
|
|
const allowedRoles = ['SUPER_ADMIN', 'ORG_ADMIN', 'SITE_ADMIN', 'RECEPTIONIST'];
|
|
if (!allowedRoles.includes(session.user.role)) {
|
|
return NextResponse.json(
|
|
{ error: 'Forbidden: Insufficient permissions' },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
const { id } = await context.params;
|
|
|
|
// Verify the cash register exists and belongs to user's organization
|
|
const cashRegister = await db.cashRegister.findFirst({
|
|
where: {
|
|
id,
|
|
site: {
|
|
organizationId: session.user.organizationId,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!cashRegister) {
|
|
return NextResponse.json(
|
|
{ error: 'Cash register not found' },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
// Check if register is already closed
|
|
if (cashRegister.closedAt) {
|
|
return NextResponse.json(
|
|
{ error: 'Cannot add transactions to a closed cash register' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// If user is SITE_ADMIN or RECEPTIONIST, verify they have access to this site
|
|
if (['SITE_ADMIN', 'RECEPTIONIST'].includes(session.user.role) && session.user.siteId !== cashRegister.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 = manualTransactionSchema.safeParse(body);
|
|
if (!validationResult.success) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Invalid transaction data',
|
|
details: validationResult.error.flatten().fieldErrors,
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const { type, amount, reason } = validationResult.data;
|
|
|
|
// For withdrawals, verify there's enough cash in the register
|
|
if (type === 'WITHDRAWAL') {
|
|
// Get current cash balance
|
|
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 currentCashBalance = Number(cashRegister.openingAmount) + totalCashPayments;
|
|
|
|
if (amount > currentCashBalance) {
|
|
return NextResponse.json(
|
|
{ error: `Insufficient cash balance. Available: ${currentCashBalance.toFixed(2)}, Requested: ${amount.toFixed(2)}` },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// Create the payment record
|
|
// For deposits, amount is positive; for withdrawals, amount is negative in the payment record
|
|
const paymentAmount = type === 'WITHDRAWAL' ? -amount : amount;
|
|
|
|
const transaction = await db.payment.create({
|
|
data: {
|
|
amount: new Decimal(paymentAmount),
|
|
paymentType: 'CASH',
|
|
notes: `${type}: ${reason}`,
|
|
cashRegisterId: id,
|
|
},
|
|
});
|
|
|
|
return NextResponse.json({
|
|
id: transaction.id,
|
|
type,
|
|
amount: Math.abs(Number(transaction.amount)),
|
|
reason,
|
|
createdAt: transaction.createdAt,
|
|
message: `${type.toLowerCase()} of ${amount.toFixed(2)} recorded successfully`,
|
|
}, { status: 201 });
|
|
} catch (error) {
|
|
console.error('Error creating transaction:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Failed to create transaction' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|