Entreprise POS System
This commit is contained in:
parent
f5648adb2f
commit
b141425440
@ -68,6 +68,7 @@ const receipt_templatesRoutes = require('./routes/receipt_templates');
|
||||
const devicesRoutes = require('./routes/devices');
|
||||
|
||||
const audit_eventsRoutes = require('./routes/audit_events');
|
||||
const posRoutes = require('./routes/pos');
|
||||
|
||||
|
||||
const getBaseUrl = (url) => {
|
||||
@ -173,6 +174,8 @@ app.use('/api/devices', passport.authenticate('jwt', {session: false}), devicesR
|
||||
|
||||
app.use('/api/audit_events', passport.authenticate('jwt', {session: false}), audit_eventsRoutes);
|
||||
|
||||
app.use('/api/pos', passport.authenticate('jwt', {session: false}), posRoutes);
|
||||
|
||||
app.use(
|
||||
'/api/openai',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
|
||||
31
backend/src/routes/pos.js
Normal file
31
backend/src/routes/pos.js
Normal file
@ -0,0 +1,31 @@
|
||||
const express = require('express');
|
||||
|
||||
const PosService = require('../services/pos');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
const { checkPermissions } = require('../middlewares/check-permissions');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post(
|
||||
'/open-session',
|
||||
checkPermissions('CREATE_REGISTER_SESSIONS'),
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await PosService.openSession(req.body, req.currentUser);
|
||||
res.status(200).send(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/checkout',
|
||||
checkPermissions('CREATE_SALES'),
|
||||
checkPermissions('CREATE_SALE_ITEMS'),
|
||||
checkPermissions('CREATE_PAYMENTS'),
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await PosService.checkout(req.body, req.currentUser);
|
||||
res.status(200).send(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
router.use('/', require('../helpers').commonErrorHandler);
|
||||
|
||||
module.exports = router;
|
||||
334
backend/src/services/pos.js
Normal file
334
backend/src/services/pos.js
Normal file
@ -0,0 +1,334 @@
|
||||
const db = require('../db/models');
|
||||
const DesksDBApi = require('../db/api/desks');
|
||||
const PaymentsDBApi = require('../db/api/payments');
|
||||
const Register_sessionsDBApi = require('../db/api/register_sessions');
|
||||
const Sale_itemsDBApi = require('../db/api/sale_items');
|
||||
const SalesDBApi = require('../db/api/sales');
|
||||
|
||||
const createBadRequest = (message) => {
|
||||
const error = new Error(message);
|
||||
error.code = 400;
|
||||
return error;
|
||||
};
|
||||
|
||||
const toNumber = (value) => {
|
||||
const parsed = Number(value);
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round(parsed * 100) / 100;
|
||||
};
|
||||
|
||||
const toMoneyString = (value) => toNumber(value).toFixed(2);
|
||||
|
||||
const getOrganizationId = (currentUser) =>
|
||||
currentUser?.organizations?.id || currentUser?.organizationsId || null;
|
||||
|
||||
const buildReceiptNumber = () => {
|
||||
const timestamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
|
||||
const suffix = Math.floor(1000 + Math.random() * 9000);
|
||||
|
||||
return `POS-${timestamp}-${suffix}`;
|
||||
};
|
||||
|
||||
module.exports = class PosService {
|
||||
static async openSession(data, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
const deskId = data?.deskId;
|
||||
|
||||
if (!deskId) {
|
||||
throw createBadRequest('Select a desk before opening a register session.');
|
||||
}
|
||||
|
||||
const desk = await DesksDBApi.findBy({ id: deskId }, { transaction });
|
||||
|
||||
if (!desk) {
|
||||
throw createBadRequest('The selected desk could not be found.');
|
||||
}
|
||||
|
||||
const organizationId = getOrganizationId(currentUser) || desk?.organizations?.id || null;
|
||||
const existingSession = await db.register_sessions.findOne({
|
||||
where: {
|
||||
deskId,
|
||||
status: 'open',
|
||||
...(organizationId ? { organizationsId: organizationId } : {}),
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (existingSession) {
|
||||
const payload = await Register_sessionsDBApi.findBy(
|
||||
{ id: existingSession.id },
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
return {
|
||||
reused: true,
|
||||
session: payload,
|
||||
};
|
||||
}
|
||||
|
||||
const openingCashAmount = toNumber(data?.openingCashAmount);
|
||||
|
||||
if (openingCashAmount < 0) {
|
||||
throw createBadRequest('Opening cash cannot be negative.');
|
||||
}
|
||||
|
||||
const session = await Register_sessionsDBApi.create(
|
||||
{
|
||||
opened_at: new Date(),
|
||||
status: 'open',
|
||||
opening_cash_amount: toMoneyString(openingCashAmount),
|
||||
expected_cash_amount: toMoneyString(openingCashAmount),
|
||||
desk: deskId,
|
||||
store: desk?.store?.id || desk?.storeId || null,
|
||||
opened_by_user: currentUser.id,
|
||||
organizations: organizationId,
|
||||
},
|
||||
{
|
||||
currentUser,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
|
||||
const payload = await Register_sessionsDBApi.findBy(
|
||||
{ id: session.id },
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
return {
|
||||
reused: false,
|
||||
session: payload,
|
||||
};
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async checkout(data, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
const registerSessionId = data?.register_sessionId;
|
||||
const deskId = data?.deskId;
|
||||
const cartItems = Array.isArray(data?.items) ? data.items : [];
|
||||
const paymentMethod = data?.payment?.method;
|
||||
const paymentReference = data?.payment?.reference || null;
|
||||
const paymentLast4 = data?.payment?.card_last4 || null;
|
||||
const notes = data?.notes || null;
|
||||
|
||||
if (!registerSessionId) {
|
||||
throw createBadRequest('An open register session is required to complete checkout.');
|
||||
}
|
||||
|
||||
if (!deskId) {
|
||||
throw createBadRequest('Select a desk before taking payment.');
|
||||
}
|
||||
|
||||
if (!cartItems.length) {
|
||||
throw createBadRequest('Add at least one product to the cart before checkout.');
|
||||
}
|
||||
|
||||
if (!['cash', 'card'].includes(paymentMethod)) {
|
||||
throw createBadRequest('Select cash or card as the payment method.');
|
||||
}
|
||||
|
||||
const registerSession = await Register_sessionsDBApi.findBy(
|
||||
{ id: registerSessionId },
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
if (!registerSession) {
|
||||
throw createBadRequest('The selected register session could not be found.');
|
||||
}
|
||||
|
||||
if (registerSession.status !== 'open') {
|
||||
throw createBadRequest('Only open register sessions can accept new sales.');
|
||||
}
|
||||
|
||||
if (registerSession?.desk?.id && registerSession.desk.id !== deskId) {
|
||||
throw createBadRequest('The selected desk does not match the active register session.');
|
||||
}
|
||||
|
||||
const organizationId = getOrganizationId(currentUser) || registerSession?.organizations?.id || null;
|
||||
const storeId =
|
||||
registerSession?.store?.id ||
|
||||
registerSession?.storeId ||
|
||||
registerSession?.desk?.store?.id ||
|
||||
registerSession?.desk?.storeId ||
|
||||
null;
|
||||
|
||||
if (!storeId) {
|
||||
throw createBadRequest('The selected desk must belong to a store before checkout can continue.');
|
||||
}
|
||||
|
||||
const normalizedItems = [];
|
||||
let subtotalAmount = 0;
|
||||
let taxAmount = 0;
|
||||
let totalAmount = 0;
|
||||
|
||||
for (const item of cartItems) {
|
||||
if (!item?.productId) {
|
||||
throw createBadRequest('Each cart line must reference a product.');
|
||||
}
|
||||
|
||||
const product = await db.products.findByPk(item.productId, {
|
||||
include: [
|
||||
{
|
||||
model: db.tax_rates,
|
||||
as: 'tax_rate',
|
||||
},
|
||||
],
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw createBadRequest('One of the products in the cart no longer exists.');
|
||||
}
|
||||
|
||||
const quantity = Number(item.quantity);
|
||||
|
||||
if (!Number.isFinite(quantity) || quantity <= 0) {
|
||||
throw createBadRequest('Each cart line must have a quantity greater than zero.');
|
||||
}
|
||||
|
||||
const unitPrice = toNumber(item.unitPrice ?? product.default_price);
|
||||
const lineSubtotalAmount = toNumber(unitPrice * quantity);
|
||||
const taxRate = product.is_taxable ? toNumber(product?.tax_rate?.rate_percent) : 0;
|
||||
const lineTaxAmount = toNumber((lineSubtotalAmount * taxRate) / 100);
|
||||
const lineTotalAmount = toNumber(lineSubtotalAmount + lineTaxAmount);
|
||||
|
||||
subtotalAmount += lineSubtotalAmount;
|
||||
taxAmount += lineTaxAmount;
|
||||
totalAmount += lineTotalAmount;
|
||||
|
||||
normalizedItems.push({
|
||||
product,
|
||||
quantity,
|
||||
unitPrice,
|
||||
lineTaxAmount,
|
||||
lineTotalAmount,
|
||||
});
|
||||
}
|
||||
|
||||
subtotalAmount = toNumber(subtotalAmount);
|
||||
taxAmount = toNumber(taxAmount);
|
||||
totalAmount = toNumber(totalAmount);
|
||||
|
||||
const amountPaid =
|
||||
paymentMethod === 'cash'
|
||||
? toNumber(data?.payment?.amount_paid)
|
||||
: totalAmount;
|
||||
|
||||
if (paymentMethod === 'cash' && amountPaid < totalAmount) {
|
||||
throw createBadRequest('Cash tendered must be greater than or equal to the sale total.');
|
||||
}
|
||||
|
||||
const changeDueAmount =
|
||||
paymentMethod === 'cash'
|
||||
? toNumber(amountPaid - totalAmount)
|
||||
: 0;
|
||||
|
||||
const sale = await SalesDBApi.create(
|
||||
{
|
||||
receipt_number: buildReceiptNumber(),
|
||||
sold_at: new Date(),
|
||||
status: 'paid',
|
||||
subtotal_amount: toMoneyString(subtotalAmount),
|
||||
discount_amount: toMoneyString(0),
|
||||
tax_amount: toMoneyString(taxAmount),
|
||||
total_amount: toMoneyString(totalAmount),
|
||||
amount_paid: toMoneyString(amountPaid),
|
||||
change_due_amount: toMoneyString(changeDueAmount),
|
||||
notes,
|
||||
store: storeId,
|
||||
desk: deskId,
|
||||
register_session: registerSessionId,
|
||||
cashier_user: currentUser.id,
|
||||
organizations: organizationId,
|
||||
},
|
||||
{
|
||||
currentUser,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
|
||||
for (const item of normalizedItems) {
|
||||
await Sale_itemsDBApi.create(
|
||||
{
|
||||
item_name_snapshot: item.product.product_name,
|
||||
sku_snapshot: item.product.sku,
|
||||
unit_price: toMoneyString(item.unitPrice),
|
||||
quantity: String(item.quantity),
|
||||
discount_amount: toMoneyString(0),
|
||||
tax_amount: toMoneyString(item.lineTaxAmount),
|
||||
line_total_amount: toMoneyString(item.lineTotalAmount),
|
||||
sale: sale.id,
|
||||
product: item.product.id,
|
||||
organizations: organizationId,
|
||||
},
|
||||
{
|
||||
currentUser,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await PaymentsDBApi.create(
|
||||
{
|
||||
paid_at: new Date(),
|
||||
method: paymentMethod,
|
||||
amount: toMoneyString(totalAmount),
|
||||
status: 'captured',
|
||||
reference: paymentReference,
|
||||
card_last4: paymentMethod === 'card' ? paymentLast4 : null,
|
||||
provider: paymentMethod === 'card' ? 'Card Terminal' : 'Cash Drawer',
|
||||
sale: sale.id,
|
||||
organizations: organizationId,
|
||||
},
|
||||
{
|
||||
currentUser,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
|
||||
if (paymentMethod === 'cash') {
|
||||
const baseExpectedCash =
|
||||
registerSession?.expected_cash_amount !== null && registerSession?.expected_cash_amount !== undefined
|
||||
? toNumber(registerSession.expected_cash_amount)
|
||||
: toNumber(registerSession?.opening_cash_amount);
|
||||
|
||||
await Register_sessionsDBApi.update(
|
||||
registerSessionId,
|
||||
{
|
||||
expected_cash_amount: toMoneyString(baseExpectedCash + totalAmount),
|
||||
},
|
||||
{
|
||||
currentUser,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const payload = await SalesDBApi.findBy({ id: sale.id }, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
return {
|
||||
sale: payload,
|
||||
};
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user