Entreprise POS System

This commit is contained in:
Flatlogic Bot 2026-05-02 08:26:44 +00:00
parent f5648adb2f
commit b141425440
3 changed files with 368 additions and 0 deletions

View File

@ -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
View 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
View 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;
}
}
};