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:
238
apps/web/app/api/sales/[id]/route.ts
Normal file
238
apps/web/app/api/sales/[id]/route.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// GET /api/sales/[id] - Get a single sale with items, payments, and products
|
||||
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 sale = await db.sale.findFirst({
|
||||
where: {
|
||||
id,
|
||||
createdBy: {
|
||||
organizationId: session.user.organizationId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
items: {
|
||||
include: {
|
||||
product: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
sku: true,
|
||||
price: true,
|
||||
image: true,
|
||||
category: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
payments: {
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
paymentType: true,
|
||||
reference: true,
|
||||
notes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
client: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
},
|
||||
},
|
||||
createdBy: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
cashRegister: {
|
||||
select: {
|
||||
id: true,
|
||||
openedAt: true,
|
||||
closedAt: true,
|
||||
site: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!sale) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Sale not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(sale);
|
||||
} catch (error) {
|
||||
console.error('Error fetching sale:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch sale' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/sales/[id] - Void/cancel a sale and restore stock
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user has admin role
|
||||
const allowedRoles = ['SUPER_ADMIN', 'ORG_ADMIN', 'SITE_ADMIN'];
|
||||
if (!allowedRoles.includes(session.user.role)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden: Insufficient permissions to void sales' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
|
||||
// Fetch the sale with items
|
||||
const sale = await db.sale.findFirst({
|
||||
where: {
|
||||
id,
|
||||
createdBy: {
|
||||
organizationId: session.user.organizationId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
items: {
|
||||
include: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
cashRegister: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!sale) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Sale not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// If sale is associated with a closed cash register, we cannot void it
|
||||
if (sale.cashRegister && sale.cashRegister.closedAt) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot void a sale from a closed cash register' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// If user is SITE_ADMIN, verify they have access to the cash register's site
|
||||
if (session.user.role === 'SITE_ADMIN' && sale.cashRegister) {
|
||||
if (session.user.siteId !== sale.cashRegister.siteId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden: You do not have access to void this sale' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Void the sale within a transaction
|
||||
await db.$transaction(async (tx) => {
|
||||
// Restore stock for each item
|
||||
for (const item of sale.items) {
|
||||
if (item.product.trackStock) {
|
||||
await tx.product.update({
|
||||
where: { id: item.productId },
|
||||
data: {
|
||||
stock: {
|
||||
increment: item.quantity,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Delete payments associated with this sale
|
||||
await tx.payment.deleteMany({
|
||||
where: { saleId: id },
|
||||
});
|
||||
|
||||
// Delete sale items
|
||||
await tx.saleItem.deleteMany({
|
||||
where: { saleId: id },
|
||||
});
|
||||
|
||||
// Delete the sale
|
||||
await tx.sale.delete({
|
||||
where: { id },
|
||||
});
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Sale voided successfully',
|
||||
restoredItems: sale.items.map(item => ({
|
||||
productId: item.productId,
|
||||
productName: item.product.name,
|
||||
quantity: item.quantity,
|
||||
stockRestored: item.product.trackStock,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error voiding sale:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to void sale' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user